Compare commits

..
6 Commits
31 changed files with 4588 additions and 137 deletions
+26
View File
@@ -16,7 +16,33 @@ permissions:
packages: write packages: write
jobs: jobs:
e2e-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ">=1.25"
- name: Install dependencies
run: go mod download
- uses: engineerd/[email protected]
- name: Run e2e tests
run: |
# rename context to docker-desktop
kubectl config rename-context "$(kubectl config current-context)" docker-desktop
cd e2e
./run-all-tests.sh
release: release:
needs: e2e-tests
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with: with:
go-version: ">=1.25" go-version: ">=1.25"
+28
View File
@@ -587,6 +587,7 @@ When running the binary directly:
- `--worker-threads int` - Concurrent workers (default: 5) - `--worker-threads int` - Concurrent workers (default: 5)
- `--rate-limit-qps float32` - API rate limit (default: 50.0) - `--rate-limit-qps float32` - API rate limit (default: 50.0)
- `--rate-limit-burst int` - API burst limit (default: 100) - `--rate-limit-burst int` - API burst limit (default: 100)
- `--verify-source-freshness` - Verify cache freshness before mirroring (default: false)
**Namespace Filtering:** **Namespace Filtering:**
- `--excluded-namespaces string` - Comma-separated exclusion list - `--excluded-namespaces string` - Comma-separated exclusion list
@@ -665,6 +666,7 @@ kubectl logs -n kubemirror-system -l app.kubernetes.io/name=kubemirror | grep "d
- **Worker Pools:** Concurrent reconciliation with configurable parallelism - **Worker Pools:** Concurrent reconciliation with configurable parallelism
- **Rate Limiting:** Protects API server with configurable QPS and burst - **Rate Limiting:** Protects API server with configurable QPS and burst
- **Bounded Queues:** Prevents memory leaks under high load - **Bounded Queues:** Prevents memory leaks under high load
- **Cache Freshness Verification (Optional):** When `--verify-source-freshness=true`, compares cached source with direct API read to detect informer cache lag. Prevents mirroring stale data during the 5-20 second window after watch events. Trade-off: Extra API call when cache is stale, but guarantees data freshness (see [Cache Staleness](#cache-staleness) for details)
## Supported Resources ## Supported Resources
@@ -696,6 +698,32 @@ KubeMirror can mirror any namespaced Kubernetes resource that supports standard
**Auto-Discovery** automatically finds all supported resources. The deny list is comprehensive and prevents mirroring of dangerous or inappropriate resources. **Auto-Discovery** automatically finds all supported resources. The deny list is comprehensive and prevents mirroring of dangerous or inappropriate resources.
## Cache Staleness
Kubernetes controllers use informer caches for performance. KubeMirror implements a hybrid strategy to handle cache lag:
**The Problem:**
1. Source Secret updated → Watch event arrives
2. Reconciliation triggered immediately3. Controller reads from cache → **Gets stale data** (cache hasn't updated yet)
4. Stale data mirrored to targets5. Cache updates 5-20 seconds later → But reconciliation already ran
**The Solution (Optional):**
Enable `--verify-source-freshness=true` to activate hybrid caching:
1. Read from cache (fast)
2. Make direct API call to verify freshness
3. If resourceVersions differ → Use fresh API data
4. If resourceVersions match → Use cached data
**Trade-offs:**
| Mode | API Calls | Data Freshness | Use Case |
|------|-----------|----------------|----------|
| **Default** (`false`) | 0 extra calls | Eventually consistent (5-20s lag) | Most deployments - 95%+ of updates propagate correctly |
| **Freshness Verification** (`true`) | 1-2 extra calls per update | Always fresh | Critical secrets that must propagate immediately |
**Recommendation:** Default mode is sufficient for most use cases. Enable freshness verification only for environments where stale data is unacceptable (e.g., security-critical secrets, zero-downtime deployments).
## Monitoring ## Monitoring
KubeMirror exposes Prometheus metrics and includes production-ready monitoring resources: KubeMirror exposes Prometheus metrics and includes production-ready monitoring resources:
@@ -43,6 +43,9 @@ spec:
- --worker-threads={{ .Values.controller.workerThreads }} - --worker-threads={{ .Values.controller.workerThreads }}
- --rate-limit-qps={{ .Values.controller.rateLimitQPS }} - --rate-limit-qps={{ .Values.controller.rateLimitQPS }}
- --rate-limit-burst={{ .Values.controller.rateLimitBurst }} - --rate-limit-burst={{ .Values.controller.rateLimitBurst }}
{{- if .Values.controller.verifySourceFreshness }}
- --verify-source-freshness=true
{{- end }}
{{- if .Values.controller.excludedNamespaces }} {{- if .Values.controller.excludedNamespaces }}
- --excluded-namespaces={{ .Values.controller.excludedNamespaces }} - --excluded-namespaces={{ .Values.controller.excludedNamespaces }}
{{- end }} {{- end }}
+6
View File
@@ -60,6 +60,12 @@ controller:
rateLimitQPS: 50.0 rateLimitQPS: 50.0
rateLimitBurst: 100 rateLimitBurst: 100
# Cache freshness verification
# Compares cache with direct API read to detect informer cache lag
# Prevents mirroring stale data but adds extra API call when cache is stale
# Recommended: false for most deployments (eventual consistency is acceptable)
verifySourceFreshness: false
# Namespace filtering # Namespace filtering
excludedNamespaces: "" excludedNamespaces: ""
includedNamespaces: "" includedNamespaces: ""
+59 -17
View File
@@ -33,18 +33,20 @@ func init() {
func main() { func main() {
var ( var (
metricsAddr string metricsAddr string
probeAddr string probeAddr string
enableLeaderElection bool enableLeaderElection bool
leaderElectionID string leaderElectionID string
excludedNamespaces string excludedNamespaces string
includedNamespaces string includedNamespaces string
resourceTypes string resourceTypes string
discoveryInterval time.Duration discoveryInterval time.Duration
maxTargets int maxTargets int
workerThreads int workerThreads int
rateLimitQPS float64 rateLimitQPS float64
rateLimitBurst int rateLimitBurst int
resyncPeriod time.Duration
verifySourceFreshness bool
) )
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
@@ -71,6 +73,12 @@ func main() {
"QPS rate limit for API server requests.") "QPS rate limit for API server requests.")
flag.IntVar(&rateLimitBurst, "rate-limit-burst", 100, flag.IntVar(&rateLimitBurst, "rate-limit-burst", 100,
"Burst limit for API server requests.") "Burst limit for API server requests.")
flag.DurationVar(&resyncPeriod, "resync-period", 30*time.Second,
"Period for resyncing all resources (catches updates missed due to informer cache delays).")
flag.BoolVar(&verifySourceFreshness, "verify-source-freshness", false,
"Verify source resource freshness by comparing cache with direct API read. "+
"Prevents mirroring stale data when cache lags behind watch events. "+
"Trade-off: Extra API call when cache is stale.")
opts := zap.Options{ opts := zap.Options{
Development: true, Development: true,
@@ -95,6 +103,7 @@ func main() {
RateLimitBurst: rateLimitBurst, RateLimitBurst: rateLimitBurst,
EnableAllKeyword: true, EnableAllKeyword: true,
RequireNamespaceOptIn: false, RequireNamespaceOptIn: false,
VerifySourceFreshness: verifySourceFreshness,
LeaderElection: config.LeaderElectionConfig{ LeaderElection: config.LeaderElectionConfig{
Enabled: enableLeaderElection, Enabled: enableLeaderElection,
ResourceName: leaderElectionID, ResourceName: leaderElectionID,
@@ -210,25 +219,58 @@ func main() {
"kind", gvk.Kind, "kind", gvk.Kind,
) )
// Create a reconciler instance for this specific resource type // Create a source reconciler instance for this specific resource type
reconciler := &controller.SourceReconciler{ sourceReconciler := &controller.SourceReconciler{
Client: mgr.GetClient(), Client: mgr.GetClient(),
Scheme: mgr.GetScheme(), Scheme: mgr.GetScheme(),
Config: cfg, Config: cfg,
Filter: namespaceFilter, Filter: namespaceFilter,
NamespaceLister: namespaceLister, NamespaceLister: namespaceLister,
GVK: gvk, GVK: gvk,
APIReader: mgr.GetAPIReader(), // Direct API reader (bypasses cache)
} }
if err = reconciler.SetupWithManagerForResourceType(mgr, gvk); err != nil { if err = sourceReconciler.SetupWithManagerForResourceType(mgr, gvk); err != nil {
setupLog.Error(err, "unable to create controller", setupLog.Error(err, "unable to create source controller",
"resourceType", rt.String(),
)
os.Exit(1)
}
// Create a mirror reconciler instance for orphan detection
// This watches mirrored resources (with managed-by label) and verifies their source still exists
mirrorReconciler := &controller.MirrorReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
GVK: gvk,
}
if err = mirrorReconciler.SetupWithManager(mgr, gvk); err != nil {
setupLog.Error(err, "unable to create mirror controller",
"resourceType", rt.String(), "resourceType", rt.String(),
) )
os.Exit(1) os.Exit(1)
} }
} }
setupLog.Info("registered controllers", "count", len(cfg.MirroredResourceTypes)) setupLog.Info("registered source and mirror controllers", "count", len(cfg.MirroredResourceTypes))
// Register namespace reconciler to watch for new namespaces and label changes
namespaceReconciler := &controller.NamespaceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Config: cfg,
Filter: namespaceFilter,
NamespaceLister: namespaceLister,
ResourceTypes: cfg.MirroredResourceTypes,
}
if err = namespaceReconciler.SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create namespace reconciler")
os.Exit(1)
}
setupLog.Info("registered namespace reconciler")
// Add health checks // Add health checks
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
+11
View File
@@ -0,0 +1,11 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: kubemirror-controller
namespace: kubemirror-system
spec:
template:
spec:
containers:
- name: controller
imagePullPolicy: IfNotPresent
+11 -8
View File
@@ -1,19 +1,22 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization kind: Kustomization
namespace: kubemirror-system namespace: kubemirror-system
commonLabels: commonLabels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/managed-by: kustomize app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/name: kubemirror
resources: resources:
- namespace.yaml - namespace.yaml
- rbac.yaml - rbac.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
images: images:
- name: ghcr.io/lukaszraczylo/kubemirror - name: ghcr.io/lukaszraczylo/kubemirror
newTag: latest newName: kubemirror
newTag: local-test
patches:
- path: imagepullpolicy-patch.yaml
+468
View File
@@ -0,0 +1,468 @@
# KubeMirror E2E Tests
Comprehensive, DRY (Don't Repeat Yourself) test framework for KubeMirror functionality.
## Overview
The test suite uses a **data-driven framework** approach where test scenarios are systematically defined and executed using reusable functions. This ensures comprehensive coverage of all edge cases without code duplication.
## Prerequisites
- Kubernetes cluster running (tested with docker-desktop)
- kubectl configured and pointing to docker-desktop context
- Go 1.21+ installed
- curl (for health checks)
## Test Architecture
### Test Framework Components
1. **common.sh**: Base utilities (logging, assertions, cleanup)
2. **test-framework.sh**: DRY test framework functions (resource creation, updates, verification)
3. **test-comprehensive.sh**: Comprehensive test scenarios using the framework (supports selective execution)
4. **test-parallel.sh**: Parallel test runner for faster execution (batches independent tests)
5. **run-all-tests.sh**: Main test runner (builds binary, starts controller, runs tests)
### Test Framework Functions
The framework provides reusable functions for all operations:
```bash
# Resource lifecycle
create_source <type> <name> <namespace> <has_label> <has_annotation> <targets> <data>
update_source_labels <type> <name> <namespace> <enabled_value>
update_source_annotations <type> <name> <namespace> <sync_value> <targets>
update_source_data <type> <name> <namespace> <new_data>
# Namespace operations
create_test_namespace <name> <allow_mirrors_label>
update_namespace_labels <namespace> <allow_mirrors_value>
delete_namespace <namespace>
# Verification functions
verify_mirrors_exist <type> <name> <namespace1> <namespace2> ...
verify_mirrors_not_exist <type> <name> <namespace1> <namespace2> ...
verify_mirror_data <type> <source_name> <source_ns> <target_ns> <expected_data>
verify_orphan_cleanup <type> <name> <namespace1> <namespace2> ...
```
## Comprehensive Test Suite
The comprehensive test suite (`test-comprehensive.sh`) covers **30 systematic scenarios**:
### Source Lifecycle Scenarios
1. **Source without labels/annotations**: No mirrors created
2. **Add enabled label**: Still no mirrors (sync annotation required)
3. **Add sync annotation**: Mirrors created in targets
4. **Modify source data**: Mirrors updated
5. **Set sync to false**: All mirrors deleted
6. **Set enabled to false**: All mirrors deleted
### Target Namespace Management
7. **Add namespace to list**: New mirror created
8. **Remove namespace from list**: Orphaned mirror deleted
9. **Change list to pattern**: Old mirrors deleted, new pattern mirrors created
10. **Multiple patterns**: Mirrors in all matching namespaces
### Pattern Matching
11. **Create namespace matching pattern**: Automatic mirror creation
12. **Mix explicit + pattern**: Both types work together
13. **Change pattern**: Orphaned mirrors cleaned up
### 'all' Keyword with Opt-in
14. **'all' without namespace label**: No mirror created
15. **Add allow-mirrors label**: Mirror created
16. **Remove allow-mirrors label**: Mirror deleted
17. **Change label true→false**: Mirror deleted
### Edge Cases
18. **Target namespace deleted**: Other mirrors unaffected
19. **Recreate deleted namespace**: Mirror recreated
20. **Source deleted**: Cascade deletion of all mirrors
21. **Target manually deleted**: Automatic recreation
22. **Remove sync annotation**: All mirrors deleted
### Resource Types
23. **Mixed resource types**: ConfigMaps alongside Secrets
24. **Custom Resource (Traefik Middleware)**: CRD mirroring
### Transformation Scenarios (24-30)
25. **Static value transformation**: Replace data values with static strings
26. **Template transformation**: Use Go templates with context variables
27. **Merge transformation**: Merge new data into existing fields
28. **Delete transformation**: Remove specific fields
29. **Multiple transformations**: Combine multiple rules
30. **Strict mode**: Fail on transformation errors vs skip
All basic scenarios tested with both **Secrets** and **ConfigMaps**.
## Running Tests
### Run Complete Test Suite (Sequential)
```bash
cd e2e
./run-all-tests.sh
```
This will:
1. Check you're on docker-desktop context
2. Build the KubeMirror binary
3. Start the controller in background
4. Run comprehensive test scenarios (all 30 scenarios sequentially)
5. Report detailed results with pass/fail for each
6. Clean up all resources automatically
**Performance**: ~5-7 minutes for all 30 scenarios
### Run Complete Test Suite (Parallel) - **FASTER** ⚡
```bash
cd e2e
# Start controller first
../kubemirror --max-targets=100 --worker-threads=5 > /tmp/kubemirror-test.log 2>&1 &
# Run tests in parallel batches
./test-parallel.sh
```
This runs independent tests in parallel batches:
- **Sequential**: Scenarios 1-11 (core lifecycle - must run sequentially)
- **Parallel Batch 1**: Scenarios 12-15 (namespace labels)
- **Parallel Batch 2**: Scenarios 16-19 (deletion scenarios)
- **Parallel Batch 3**: Scenarios 20-23 (mixed resources)
- **Parallel Batch 4**: Scenarios 24-27 (transformations part 1)
- **Parallel Batch 5**: Scenarios 28-30 (transformations part 2)
**Performance**: ~3-4 minutes (40-50% faster than sequential)
### Run Selective Scenarios
Run only specific scenarios for faster iteration during development:
```bash
# Must have KubeMirror controller running first
cd /Users/nvm/Documents/projects/private/kube-mirror
./kubemirror --max-targets=100 --worker-threads=5 > /tmp/kubemirror-test.log 2>&1 &
# Run only transformation tests (scenarios 24-30)
cd e2e
./test-comprehensive.sh 24 25 26 27 28 29 30
# Run only specific scenarios
./test-comprehensive.sh 1 2 3
# Run single scenario for debugging
./test-comprehensive.sh 24
```
**Performance**: <1 minute for a few scenarios
## Test Output
Each test produces colored output:
- 🔵 **[INFO]**: Informational messages
-**[PASS]**: Test passed
-**[FAIL]**: Test failed
- ⚠️ **[WARN]**: Warning messages
Example output:
```
======================================
KubeMirror E2E Test Suite
======================================
[INFO] Step 1: Checking Kubernetes context
[PASS] Running on docker-desktop context
[INFO] Step 2: Building KubeMirror binary
[PASS] KubeMirror binary built successfully
[INFO] Step 3: Starting KubeMirror controller
[INFO] KubeMirror started with PID: 12345
[PASS] Controller is healthy
======================================
Running Test Suite 1: Basic Mirroring
======================================
[INFO] Starting Basic Mirroring tests
[INFO] Test 1: Mirror Secret to explicit namespace list
[PASS] Resource secret/test-explicit-list-secret exists in namespace e2e-target-1
[PASS] Resource secret/test-explicit-list-secret exists in namespace e2e-target-2
...
======================================
Test Summary
======================================
Total Tests: 45
Passed: 45
Failed: 0
======================================
All tests passed!
```
## Test Resources
Tests create temporary resources with clear naming for isolation:
- **Source Namespace**: `kubemirror-e2e-source` (dedicated namespace for all test source resources)
- **Target Namespaces**: `kubemirror-e2e-*` prefixed (ns-1, ns-2, app-1, db-1, etc.)
- **Secrets**: `test-*` prefixed in source namespace
- **ConfigMaps**: `test-*` prefixed in source namespace
- **CRDs**: Traefik Middleware resources for CRD testing
All resources are cleaned up automatically on test completion, including:
- Automatic finalizer removal from source resources (prevents hanging deletions)
- Cascade deletion of all target namespaces
- Cleanup on test interruption (SIGINT/SIGTERM)
## Troubleshooting
### Tests fail with "context not docker-desktop"
Switch to docker-desktop context:
```bash
kubectl config use-context docker-desktop
```
### Tests timeout waiting for resources
Controller may not be running or not reconciling. Check:
```bash
# Check if controller is running
ps aux | grep kubemirror
# Check controller logs
tail -f /tmp/kubemirror-e2e-test.log
# Check controller health
curl http://localhost:8081/healthz
```
### Cleanup hanging
If tests get interrupted, manually clean up:
```bash
# Delete all e2e test namespaces
kubectl delete namespace -l kubemirror-e2e-test=true
# Delete test resources in default namespace
kubectl delete secret,configmap -n default -l kubemirror.raczylo.com/enabled=true
# Kill controller if still running
pkill kubemirror
```
### Individual test fails
Run test with verbose output to see which assertion failed:
```bash
bash -x ./test-basic-mirroring.sh
```
Check controller logs for errors:
```bash
grep -i error /tmp/kubemirror-e2e-test.log
```
## Adding New Test Scenarios
The DRY framework makes it easy to add new test scenarios. Here's how:
### Example: Add a new scenario
```bash
# In test-comprehensive.sh, add a new scenario block:
run_test_scenario "23: Your new scenario description"
# Use framework functions to set up test conditions
create_test_namespace e2e-new-ns
create_source secret test-new default true true "e2e-new-ns" "test-data"
# Perform the action you want to test
update_source_annotations secret test-new default true "e2e-new-ns,e2e-new-ns-2"
# Verify expected results
verify_mirrors_exist secret test-new e2e-new-ns e2e-new-ns-2
complete_test_scenario "23" "pass"
```
### Framework Functions Reference
**Resource Creation:**
```bash
create_source secret my-secret default true true "ns1,ns2" "data"
# ↑ ↑ ↑ ↑ ↑ ↑ ↑
# type name ns lbl ann targets data
```
**Resource Updates:**
```bash
update_source_labels secret my-secret default true # Set enabled=true
update_source_labels secret my-secret default false # Set enabled=false
update_source_labels secret my-secret default "" # Remove label
update_source_annotations secret my-secret default true "ns1,ns2" # Enable sync
update_source_annotations secret my-secret default false "" # Set sync=false
update_source_annotations secret my-secret default "" "" # Remove annotation
update_source_data secret my-secret default "new-data-v2"
```
**Namespace Operations:**
```bash
create_test_namespace my-ns true # Create with allow-mirrors=true
create_test_namespace my-ns false # Create with allow-mirrors=false
create_test_namespace my-ns "" # Create with no label
update_namespace_labels my-ns true # Set allow-mirrors=true
update_namespace_labels my-ns false # Set allow-mirrors=false
update_namespace_labels my-ns "" # Remove label
```
**Verification:**
```bash
verify_mirrors_exist secret my-secret ns1 ns2 ns3
verify_mirrors_not_exist secret my-secret ns4 ns5
verify_mirror_data secret my-secret default target-ns "expected-data"
verify_orphan_cleanup secret my-secret orphan-ns1 orphan-ns2
```
## Test Coverage Summary
| Category | Scenarios | Details |
|----------|-----------|---------|
| Source lifecycle | 6 | No labels → add label → add annotation → modify → disable |
| Target management | 4 | Add/remove namespaces, change list to pattern, multiple patterns |
| Pattern matching | 3 | New namespace creation, pattern changes, mixed explicit+pattern |
| 'all' keyword opt-in | 4 | No label, add label, remove label, change true→false |
| Edge cases | 5 | Namespace deletion, recreation, source deletion, target recreation |
| Resource types | 2 | Mixed ConfigMaps, Custom Resource (Traefik Middleware) |
| Transformations | 7 | Static value, template, merge, delete, multiple, strict mode |
| **Total** | **30** | **Comprehensive coverage with multiple resource types** |
## Test Methodology
### Systematic Approach
The test framework follows a systematic approach:
1. **State Setup**: Create namespaces and resources in known state
2. **Action**: Perform the operation being tested (create, update, delete, label change)
3. **Verification**: Assert expected outcomes using verification functions
4. **Cleanup**: Automatic cleanup via trap handlers
### DRY Principles
- **Reusable functions**: All operations abstracted into framework functions
- **Data-driven**: Test scenarios are data, not code
- **Composable**: Combine framework functions to create complex scenarios
- **Maintainable**: Add new scenarios without duplicating code
### Coverage Strategy
Tests systematically cover:
- **Happy path**: Expected behavior under normal conditions
- **Edge cases**: Boundary conditions and unusual states
- **Error conditions**: Invalid inputs, missing resources, conflicts
- **State transitions**: All possible state changes (no labels → labels → annotations, etc.)
- **Concurrent operations**: Namespace creation during reconciliation, multiple updates
## Test Utilities Reference
### Common Utilities (`common.sh`)
**Logging:**
- `log_info <message>`: Blue informational message
- `log_success <message>`: Green success message (increments pass count)
- `log_fail <message>`: Red failure message (increments fail count)
- `log_warn <message>`: Yellow warning message
**Assertions:**
- `assert_resource_exists <type> <name> <namespace>`
- `assert_resource_not_exists <type> <name> <namespace>`
- `assert_annotation_exists <type> <name> <namespace> <annotation_key>`
- `assert_label_exists <type> <name> <namespace> <label_key> <expected_value>`
- `assert_data_matches <type> <source_name> <source_ns> <target_name> <target_ns> <data_key>`
**Waiting:**
- `wait_for_resource <type> <name> <namespace> [timeout]`
- `wait_for_resource_deletion <type> <name> <namespace> [timeout]`
**Utilities:**
- `cleanup_namespace <namespace>`
- `cleanup_resource <type> <name> <namespace>`
- `check_context`: Verify running on docker-desktop
- `print_summary`: Print test results summary
## CI/CD Integration
To run tests in CI:
```bash
#!/bin/bash
set -e
# Start kind cluster or use existing k8s
kind create cluster --name kubemirror-test
# Switch context
kubectl config use-context kind-kubemirror-test
# Run tests
cd e2e
./run-all-tests.sh
# Cleanup
kind delete cluster --name kubemirror-test
```
## Performance Notes
### Test Execution Times
- **Sequential execution** (test-comprehensive.sh): ~5-7 minutes for all 30 scenarios
- **Parallel execution** (test-parallel.sh): ~3-4 minutes for all 30 scenarios (40-50% faster)
- **Selective execution** (few scenarios): <1 minute
- **Controller startup**: ~10 seconds
- **Resource reconciliation**: typically <5 seconds per operation
### Test Coverage
- **Total scenarios**: 30 comprehensive scenarios
- **Total assertions**: 100+ across all scenarios
- **Resource types tested**: Secrets, ConfigMaps, Traefik Middlewares (CRDs)
- **Each scenario includes**: Setup, action, verification, and cleanup phases
### Optimization Tips
- Use `test-parallel.sh` for full test runs (40-50% faster)
- Use selective execution during development: `./test-comprehensive.sh 24 25 26`
- Run only affected scenarios after code changes
- Parallel execution is safe - batches ensure test independence
## Test Isolation and Cleanup
- **Automatic cleanup**: All resources cleaned up via trap handlers
- **Namespace isolation**:
- Dedicated source namespace: `kubemirror-e2e-source`
- Target namespaces: `kubemirror-e2e-*` prefixed
- No pollution of `default` namespace
- **Execution modes**:
- Sequential: All scenarios run in order (test-comprehensive.sh with no args)
- Parallel: Independent scenarios batched (test-parallel.sh)
- Selective: Run specific scenarios (test-comprehensive.sh 24 25 26)
- **Idempotent**: Tests can be re-run without manual cleanup
- **Resource labeling**: Test resources labeled `test-resource: e2e` for easy identification
- **Finalizer handling**: Automatic finalizer removal prevents stuck resource deletions
## Known Limitations
- Tests assume clean docker-desktop cluster (or equivalent local cluster)
- Some scenarios require waiting for reconciliation (30s default timeout)
- Controller must be stopped between runs if running manually (run-all-tests.sh handles this)
- Parallel execution requires sufficient cluster resources (5-6 tests may run concurrently)
- Some scenarios depend on previous state (scenarios 1-11 must run sequentially)
Executable
+242
View File
@@ -0,0 +1,242 @@
#!/bin/bash
# Common utilities for E2E tests
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Test counters
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
# Logging functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[PASS]${NC} $1"
((TESTS_PASSED++))
}
log_fail() {
echo -e "${RED}[FAIL]${NC} $1"
((TESTS_FAILED++))
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
# Test assertion functions
assert_resource_exists() {
local resource_type=$1
local resource_name=$2
local namespace=$3
((TESTS_RUN++))
if kubectl get "$resource_type" "$resource_name" -n "$namespace" &>/dev/null; then
log_success "Resource $resource_type/$resource_name exists in namespace $namespace"
return 0
else
log_fail "Resource $resource_type/$resource_name does NOT exist in namespace $namespace"
return 1
fi
}
assert_resource_not_exists() {
local resource_type=$1
local resource_name=$2
local namespace=$3
((TESTS_RUN++))
if kubectl get "$resource_type" "$resource_name" -n "$namespace" &>/dev/null; then
log_fail "Resource $resource_type/$resource_name EXISTS in namespace $namespace (should not exist)"
return 1
else
log_success "Resource $resource_type/$resource_name does not exist in namespace $namespace"
return 0
fi
}
assert_annotation_exists() {
local resource_type=$1
local resource_name=$2
local namespace=$3
local annotation_key=$4
((TESTS_RUN++))
# Escape dots in annotation key for jsonpath (dots need to be escaped, but not slashes)
local escaped_key="${annotation_key//./\\.}"
local annotation_value
annotation_value=$(kubectl get "$resource_type" "$resource_name" -n "$namespace" -o jsonpath="{.metadata.annotations.$escaped_key}" 2>/dev/null || echo "")
if [ -n "$annotation_value" ]; then
log_success "Annotation $annotation_key exists on $resource_type/$resource_name in namespace $namespace (value: $annotation_value)"
return 0
else
log_fail "Annotation $annotation_key does NOT exist on $resource_type/$resource_name in namespace $namespace"
return 1
fi
}
assert_label_exists() {
local resource_type=$1
local resource_name=$2
local namespace=$3
local label_key=$4
local expected_value=$5
((TESTS_RUN++))
# Escape dots in label key for jsonpath (dots need to be escaped, but not slashes)
local escaped_key="${label_key//./\\.}"
local actual_value
actual_value=$(kubectl get "$resource_type" "$resource_name" -n "$namespace" -o jsonpath="{.metadata.labels.$escaped_key}" 2>/dev/null || echo "")
if [ "$actual_value" = "$expected_value" ]; then
log_success "Label $label_key=$expected_value on $resource_type/$resource_name in namespace $namespace"
return 0
else
log_fail "Label $label_key has value '$actual_value', expected '$expected_value' on $resource_type/$resource_name in namespace $namespace"
return 1
fi
}
assert_data_matches() {
local resource_type=$1
local source_name=$2
local source_ns=$3
local target_name=$4
local target_ns=$5
local data_key=$6
((TESTS_RUN++))
local source_value target_value
if [ "$resource_type" = "secret" ]; then
source_value=$(kubectl get secret "$source_name" -n "$source_ns" -o jsonpath="{.data['$data_key']}" 2>/dev/null || echo "")
target_value=$(kubectl get secret "$target_name" -n "$target_ns" -o jsonpath="{.data['$data_key']}" 2>/dev/null || echo "")
else
source_value=$(kubectl get "$resource_type" "$source_name" -n "$source_ns" -o jsonpath="{.data['$data_key']}" 2>/dev/null || echo "")
target_value=$(kubectl get "$resource_type" "$target_name" -n "$target_ns" -o jsonpath="{.data['$data_key']}" 2>/dev/null || echo "")
fi
if [ "$source_value" = "$target_value" ] && [ -n "$source_value" ]; then
log_success "Data key '$data_key' matches between source and target"
return 0
else
log_fail "Data key '$data_key' does NOT match (source: ${source_value:0:20}..., target: ${target_value:0:20}...)"
return 1
fi
}
# Wait for resource to appear
wait_for_resource() {
local resource_type=$1
local resource_name=$2
local namespace=$3
local timeout=${4:-30}
log_info "Waiting for $resource_type/$resource_name in namespace $namespace (timeout: ${timeout}s)"
local elapsed=0
while [ $elapsed -lt $timeout ]; do
if kubectl get "$resource_type" "$resource_name" -n "$namespace" &>/dev/null; then
log_info "Resource appeared after ${elapsed}s"
return 0
fi
sleep 1
((elapsed++))
done
log_warn "Timeout waiting for $resource_type/$resource_name in namespace $namespace"
return 1
}
# Wait for resource to disappear
wait_for_resource_deletion() {
local resource_type=$1
local resource_name=$2
local namespace=$3
local timeout=${4:-30}
log_info "Waiting for $resource_type/$resource_name to be deleted from namespace $namespace (timeout: ${timeout}s)"
local elapsed=0
while [ $elapsed -lt $timeout ]; do
if ! kubectl get "$resource_type" "$resource_name" -n "$namespace" &>/dev/null; then
log_info "Resource deleted after ${elapsed}s"
return 0
fi
sleep 1
((elapsed++))
done
log_warn "Timeout waiting for $resource_type/$resource_name deletion in namespace $namespace"
return 1
}
# Check context is docker-desktop
check_context() {
local current_context
current_context=$(kubectl config current-context)
if [ "$current_context" != "docker-desktop" ]; then
log_fail "Current context is '$current_context', expected 'docker-desktop'"
log_info "Please switch context: kubectl config use-context docker-desktop"
exit 1
fi
log_success "Running on docker-desktop context"
}
# Print test summary
print_summary() {
echo ""
echo "======================================"
echo "Test Summary"
echo "======================================"
echo -e "Total Tests: ${BLUE}$TESTS_RUN${NC}"
echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}"
echo -e "Failed: ${RED}$TESTS_FAILED${NC}"
echo "======================================"
if [ $TESTS_FAILED -eq 0 ]; then
echo -e "${GREEN}All tests passed!${NC}"
return 0
else
echo -e "${RED}Some tests failed!${NC}"
return 1
fi
}
# Cleanup function
cleanup_namespace() {
local namespace=$1
if kubectl get namespace "$namespace" &>/dev/null; then
log_info "Cleaning up namespace $namespace"
kubectl delete namespace "$namespace" --ignore-not-found=true --wait=false &>/dev/null || true
fi
}
cleanup_resource() {
local resource_type=$1
local resource_name=$2
local namespace=$3
if kubectl get "$resource_type" "$resource_name" -n "$namespace" &>/dev/null; then
log_info "Cleaning up $resource_type/$resource_name in namespace $namespace"
kubectl delete "$resource_type" "$resource_name" -n "$namespace" --ignore-not-found=true &>/dev/null || true
fi
}
+200
View File
@@ -0,0 +1,200 @@
#!/bin/bash
# E2E Test: Basic Mirroring Functionality
# Tests existing mirror functionality with explicit lists, patterns, and 'all' keyword
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
TEST_NAME="Basic Mirroring"
log_info "Starting $TEST_NAME tests"
# Cleanup function for this test
cleanup() {
log_info "Cleaning up test resources"
cleanup_resource secret test-explicit-list-secret default
cleanup_resource configmap test-explicit-list-cm default
cleanup_resource secret test-pattern-secret default
cleanup_resource secret test-all-keyword-secret default
cleanup_namespace e2e-target-1
cleanup_namespace e2e-target-2
cleanup_namespace e2e-target-3
cleanup_namespace e2e-app-1
cleanup_namespace e2e-app-2
cleanup_namespace e2e-app-3
cleanup_namespace e2e-labeled-ns
sleep 5
}
# Trap cleanup on exit
trap cleanup EXIT
# Clean up any existing resources
cleanup
# Wait for cleanup to complete
sleep 3
log_info "Creating test namespaces"
kubectl create namespace e2e-target-1
kubectl create namespace e2e-target-2
kubectl create namespace e2e-target-3
kubectl create namespace e2e-app-1
kubectl create namespace e2e-app-2
kubectl create namespace e2e-app-3
# Test 1: Explicit namespace list
log_info "Test 1: Mirror Secret to explicit namespace list"
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: test-explicit-list-secret
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "e2e-target-1,e2e-target-2"
type: Opaque
stringData:
username: admin
password: secret123
EOF
wait_for_resource secret test-explicit-list-secret e2e-target-1
wait_for_resource secret test-explicit-list-secret e2e-target-2
assert_resource_exists secret test-explicit-list-secret e2e-target-1
assert_resource_exists secret test-explicit-list-secret e2e-target-2
assert_resource_not_exists secret test-explicit-list-secret e2e-target-3
assert_label_exists secret test-explicit-list-secret e2e-target-1 "kubemirror.raczylo.com/managed-by" "kubemirror"
assert_label_exists secret test-explicit-list-secret e2e-target-1 "kubemirror.raczylo.com/mirror" "true"
assert_annotation_exists secret test-explicit-list-secret e2e-target-1 "kubemirror.raczylo.com/source-namespace"
assert_annotation_exists secret test-explicit-list-secret e2e-target-1 "kubemirror.raczylo.com/source-name"
assert_data_matches secret test-explicit-list-secret default test-explicit-list-secret e2e-target-1 username
assert_data_matches secret test-explicit-list-secret default test-explicit-list-secret e2e-target-1 password
# Test 2: ConfigMap with explicit list
log_info "Test 2: Mirror ConfigMap to explicit namespace list"
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: test-explicit-list-cm
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "e2e-target-1,e2e-target-2,e2e-target-3"
data:
config.yaml: |
app: myapp
version: 1.0
EOF
wait_for_resource configmap test-explicit-list-cm e2e-target-1
wait_for_resource configmap test-explicit-list-cm e2e-target-2
wait_for_resource configmap test-explicit-list-cm e2e-target-3
assert_resource_exists configmap test-explicit-list-cm e2e-target-1
assert_resource_exists configmap test-explicit-list-cm e2e-target-2
assert_resource_exists configmap test-explicit-list-cm e2e-target-3
assert_data_matches configmap test-explicit-list-cm default test-explicit-list-cm e2e-target-1 config.yaml
# Test 3: Pattern matching
log_info "Test 3: Mirror Secret with pattern matching (e2e-app-*)"
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: test-pattern-secret
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "e2e-app-*"
type: Opaque
stringData:
api-key: abc123xyz
EOF
wait_for_resource secret test-pattern-secret e2e-app-1
wait_for_resource secret test-pattern-secret e2e-app-2
wait_for_resource secret test-pattern-secret e2e-app-3
assert_resource_exists secret test-pattern-secret e2e-app-1
assert_resource_exists secret test-pattern-secret e2e-app-2
assert_resource_exists secret test-pattern-secret e2e-app-3
assert_resource_not_exists secret test-pattern-secret e2e-target-1
assert_data_matches secret test-pattern-secret default test-pattern-secret e2e-app-1 api-key
# Test 4: 'all' keyword with labeled namespace
log_info "Test 4: Mirror Secret with 'all' keyword (requires namespace label)"
kubectl create namespace e2e-labeled-ns
kubectl label namespace e2e-labeled-ns kubemirror.raczylo.com/allow-mirrors=true
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: test-all-keyword-secret
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "all"
type: Opaque
stringData:
shared-token: token123
EOF
wait_for_resource secret test-all-keyword-secret e2e-labeled-ns
assert_resource_exists secret test-all-keyword-secret e2e-labeled-ns
# Test 5: Source update propagates to targets
log_info "Test 5: Update source and verify targets updated"
kubectl patch secret test-explicit-list-secret -n default --type merge -p '{"stringData":{"password":"newsecret456"}}'
sleep 5
target_password=$(kubectl get secret test-explicit-list-secret -n e2e-target-1 -o jsonpath='{.data.password}' | base64 -d)
if [ "$target_password" = "newsecret456" ]; then
log_success "Target secret updated with new password"
((TESTS_RUN++))
((TESTS_PASSED++))
else
log_fail "Target secret NOT updated (password: $target_password)"
((TESTS_RUN++))
((TESTS_FAILED++))
fi
# Test 6: Source deletion cascades to targets
log_info "Test 6: Delete source and verify targets deleted"
kubectl delete secret test-explicit-list-secret -n default
wait_for_resource_deletion secret test-explicit-list-secret e2e-target-1
wait_for_resource_deletion secret test-explicit-list-secret e2e-target-2
assert_resource_not_exists secret test-explicit-list-secret e2e-target-1
assert_resource_not_exists secret test-explicit-list-secret e2e-target-2
# Test 7: Target deletion triggers recreation
log_info "Test 7: Delete target and verify it's recreated"
kubectl delete configmap test-explicit-list-cm -n e2e-target-2
wait_for_resource configmap test-explicit-list-cm e2e-target-2 15
assert_resource_exists configmap test-explicit-list-cm e2e-target-2
print_summary
+236
View File
@@ -0,0 +1,236 @@
#!/bin/bash
# E2E Test: Namespace Reconciliation
# Tests new namespace reconciliation features including CREATE/UPDATE events and orphaned mirror cleanup
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
TEST_NAME="Namespace Reconciliation"
log_info "Starting $TEST_NAME tests"
# Cleanup function for this test
cleanup() {
log_info "Cleaning up test resources"
cleanup_resource secret test-ns-recon-pattern-secret default
cleanup_resource secret test-ns-recon-all-secret default
cleanup_resource secret test-orphan-cleanup-secret default
cleanup_resource configmap test-label-change-cm default
cleanup_namespace e2e-recon-app-1
cleanup_namespace e2e-recon-app-2
cleanup_namespace e2e-recon-app-3
cleanup_namespace e2e-recon-new
cleanup_namespace e2e-label-test
cleanup_namespace e2e-no-label
cleanup_namespace e2e-orphan-1
cleanup_namespace e2e-orphan-2
cleanup_namespace e2e-orphan-3
sleep 5
}
# Trap cleanup on exit
trap cleanup EXIT
# Clean up any existing resources
cleanup
# Wait for cleanup to complete
sleep 3
# Test 1: Create source with pattern, then create matching namespace
log_info "Test 1: Create namespace matching existing pattern"
# Create initial namespaces
kubectl create namespace e2e-recon-app-1
kubectl create namespace e2e-recon-app-2
# Create secret with pattern
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: test-ns-recon-pattern-secret
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "e2e-recon-app-*"
type: Opaque
stringData:
token: pattern-token-123
EOF
wait_for_resource secret test-ns-recon-pattern-secret e2e-recon-app-1
wait_for_resource secret test-ns-recon-pattern-secret e2e-recon-app-2
assert_resource_exists secret test-ns-recon-pattern-secret e2e-recon-app-1
assert_resource_exists secret test-ns-recon-pattern-secret e2e-recon-app-2
# Now create a new namespace that matches the pattern
log_info "Creating new namespace e2e-recon-app-3 (matches pattern)"
kubectl create namespace e2e-recon-app-3
# Namespace reconciler should automatically create mirror in new namespace
wait_for_resource secret test-ns-recon-pattern-secret e2e-recon-app-3 30
assert_resource_exists secret test-ns-recon-pattern-secret e2e-recon-app-3
assert_data_matches secret test-ns-recon-pattern-secret default test-ns-recon-pattern-secret e2e-recon-app-3 token
# Test 2: Create source with 'all', then create namespace without label
log_info "Test 2: Create namespace without allow-mirrors label (source has 'all')"
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: test-ns-recon-all-secret
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "all"
type: Opaque
stringData:
shared-key: all-secret-456
EOF
# Create namespace without label
log_info "Creating namespace e2e-no-label (no allow-mirrors label)"
kubectl create namespace e2e-no-label
sleep 5
# Mirror should NOT be created (namespace not opted-in)
assert_resource_not_exists secret test-ns-recon-all-secret e2e-no-label
# Test 3: Add allow-mirrors label to namespace (should trigger mirror creation)
log_info "Test 3: Add allow-mirrors label to namespace (should create mirror)"
kubectl label namespace e2e-no-label kubemirror.raczylo.com/allow-mirrors=true
# Namespace reconciler should detect label change and create mirror
wait_for_resource secret test-ns-recon-all-secret e2e-no-label 30
assert_resource_exists secret test-ns-recon-all-secret e2e-no-label
assert_data_matches secret test-ns-recon-all-secret default test-ns-recon-all-secret e2e-no-label shared-key
# Test 4: Remove allow-mirrors label from namespace (should trigger cleanup)
log_info "Test 4: Remove allow-mirrors label from namespace (should delete mirror)"
kubectl label namespace e2e-no-label kubemirror.raczylo.com/allow-mirrors-
# Namespace reconciler should detect label removal and cleanup mirror
wait_for_resource_deletion secret test-ns-recon-all-secret e2e-no-label 30
assert_resource_not_exists secret test-ns-recon-all-secret e2e-no-label
# Test 5: Change allow-mirrors label from true to false (should trigger cleanup)
log_info "Test 5: Change allow-mirrors label from true to false"
kubectl create namespace e2e-label-test
kubectl label namespace e2e-label-test kubemirror.raczylo.com/allow-mirrors=true
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: test-label-change-cm
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "all"
data:
config: "test-data"
EOF
wait_for_resource configmap test-label-change-cm e2e-label-test
assert_resource_exists configmap test-label-change-cm e2e-label-test
# Now change label to false
log_info "Changing label to false"
kubectl label namespace e2e-label-test kubemirror.raczylo.com/allow-mirrors=false --overwrite
# Should trigger cleanup
wait_for_resource_deletion configmap test-label-change-cm e2e-label-test 30
assert_resource_not_exists configmap test-label-change-cm e2e-label-test
# Test 6: Orphaned mirror cleanup when source target pattern changes
log_info "Test 6: Orphaned mirror cleanup (pattern changed to explicit list)"
# Create namespaces
kubectl create namespace e2e-orphan-1
kubectl create namespace e2e-orphan-2
kubectl create namespace e2e-orphan-3
# Create secret with pattern matching all orphan namespaces
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: test-orphan-cleanup-secret
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "e2e-orphan-*"
type: Opaque
stringData:
data: "orphan-test"
EOF
wait_for_resource secret test-orphan-cleanup-secret e2e-orphan-1
wait_for_resource secret test-orphan-cleanup-secret e2e-orphan-2
wait_for_resource secret test-orphan-cleanup-secret e2e-orphan-3
assert_resource_exists secret test-orphan-cleanup-secret e2e-orphan-1
assert_resource_exists secret test-orphan-cleanup-secret e2e-orphan-2
assert_resource_exists secret test-orphan-cleanup-secret e2e-orphan-3
# Now change pattern to explicit list (only orphan-1 and orphan-2)
log_info "Changing target-namespaces from pattern to explicit list"
kubectl annotate secret test-orphan-cleanup-secret -n default \
kubemirror.raczylo.com/target-namespaces="e2e-orphan-1,e2e-orphan-2" --overwrite
# Orphan cleanup should remove mirror from e2e-orphan-3
wait_for_resource_deletion secret test-orphan-cleanup-secret e2e-orphan-3 30
assert_resource_exists secret test-orphan-cleanup-secret e2e-orphan-1
assert_resource_exists secret test-orphan-cleanup-secret e2e-orphan-2
assert_resource_not_exists secret test-orphan-cleanup-secret e2e-orphan-3
# Test 7: Change from explicit list to different explicit list
log_info "Test 7: Change explicit list (add e2e-orphan-3, remove e2e-orphan-1)"
kubectl annotate secret test-orphan-cleanup-secret -n default \
kubemirror.raczylo.com/target-namespaces="e2e-orphan-2,e2e-orphan-3" --overwrite
# Should remove from orphan-1 and create in orphan-3
wait_for_resource secret test-orphan-cleanup-secret e2e-orphan-3 30
wait_for_resource_deletion secret test-orphan-cleanup-secret e2e-orphan-1 30
assert_resource_not_exists secret test-orphan-cleanup-secret e2e-orphan-1
assert_resource_exists secret test-orphan-cleanup-secret e2e-orphan-2
assert_resource_exists secret test-orphan-cleanup-secret e2e-orphan-3
# Test 8: Create namespace with label already set (for 'all' source)
log_info "Test 8: Create namespace with allow-mirrors label already set"
kubectl create namespace e2e-recon-new
kubectl label namespace e2e-recon-new kubemirror.raczylo.com/allow-mirrors=true
# Should automatically get mirrors from sources with 'all'
wait_for_resource secret test-ns-recon-all-secret e2e-recon-new 30
wait_for_resource configmap test-label-change-cm e2e-recon-new 30
assert_resource_exists secret test-ns-recon-all-secret e2e-recon-new
assert_resource_exists configmap test-label-change-cm e2e-recon-new
print_summary
+155
View File
@@ -0,0 +1,155 @@
#!/bin/bash
# E2E Test Runner - Runs all KubeMirror E2E tests
# Builds the binary, starts the controller, runs tests, and cleans up
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$SCRIPT_DIR/common.sh"
KUBEMIRROR_PID=""
KUBEMIRROR_LOG="/tmp/kubemirror-e2e-test.log"
KUBEMIRROR_BINARY="$PROJECT_ROOT/kubemirror"
# Cleanup function
cleanup() {
log_info "Cleaning up..."
if [ -n "$KUBEMIRROR_PID" ] && kill -0 "$KUBEMIRROR_PID" 2>/dev/null; then
log_info "Stopping KubeMirror controller (PID: $KUBEMIRROR_PID)"
kill "$KUBEMIRROR_PID" 2>/dev/null || true
wait "$KUBEMIRROR_PID" 2>/dev/null || true
fi
# Clean up any test namespaces that might be left
log_info "Cleaning up test namespaces"
kubectl delete namespace -l kubemirror-e2e-test=true --wait=false 2>/dev/null || true
# Give time for cleanup
sleep 2
}
trap cleanup EXIT
# Main execution
main() {
echo "======================================"
echo "KubeMirror E2E Test Suite"
echo "======================================"
echo ""
# Step 1: Check context
log_info "Step 1: Checking Kubernetes context"
check_context
# Step 2: Build KubeMirror
log_info "Step 2: Building KubeMirror binary"
cd "$PROJECT_ROOT" || exit 1
if ! go build -o kubemirror ./cmd/kubemirror; then
log_fail "Failed to build KubeMirror"
exit 1
fi
log_success "KubeMirror binary built successfully"
# Step 3: Install Traefik CRDs (needed for scenario 23)
log_info "Step 3: Installing Traefik CRDs"
kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/master/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
log_success "Traefik CRDs installed"
# Step 4: Start KubeMirror controller
log_info "Step 4: Starting KubeMirror controller"
rm -f "$KUBEMIRROR_LOG"
"$KUBEMIRROR_BINARY" \
--metrics-bind-address=:8080 \
--health-probe-bind-address=:8081 \
--max-targets=100 \
--worker-threads=5 \
--verify-source-freshness=true \
>"$KUBEMIRROR_LOG" 2>&1 &
KUBEMIRROR_PID=$!
log_info "KubeMirror started with PID: $KUBEMIRROR_PID"
log_info "Log file: $KUBEMIRROR_LOG"
# Wait for controller to be ready
log_info "Waiting for controller to be ready..."
sleep 10
if ! kill -0 "$KUBEMIRROR_PID" 2>/dev/null; then
log_fail "KubeMirror controller failed to start"
log_info "Last 20 lines of log:"
tail -20 "$KUBEMIRROR_LOG"
exit 1
fi
# Check health endpoint
local retries=0
while [ $retries -lt 10 ]; do
if curl -s http://localhost:8081/healthz >/dev/null 2>&1; then
log_success "Controller is healthy"
break
fi
sleep 2
((retries++))
done
if [ $retries -eq 10 ]; then
log_warn "Controller health check timeout (non-fatal)"
fi
# Give controller time to set up watches
sleep 5
# Step 5: Run test suites
log_info "Step 5: Running test suites"
echo ""
local test_results=0
# Comprehensive Test Suite
echo "======================================"
echo "Running Comprehensive E2E Test Suite"
echo "======================================"
echo "This will test all scenarios systematically:"
echo " - Source lifecycle (no labels → labels → annotations)"
echo " - Target namespace changes (add/remove from list)"
echo " - Pattern matching and changes"
echo " - 'all' keyword with namespace opt-in/opt-out"
echo " - Content updates and propagation"
echo " - Orphaned mirror cleanup"
echo " - Namespace creation/deletion/label changes"
echo ""
if bash "$SCRIPT_DIR/test-comprehensive.sh"; then
log_success "Comprehensive Test Suite PASSED"
else
log_fail "Comprehensive Test Suite FAILED"
test_results=1
fi
echo ""
# Step 6: Final summary
echo "======================================"
echo "E2E Test Run Complete"
echo "======================================"
if [ $test_results -eq 0 ]; then
echo -e "${GREEN}All test suites passed!${NC}"
log_info "Controller log available at: $KUBEMIRROR_LOG"
return 0
else
echo -e "${RED}Some test suites failed!${NC}"
log_info "Controller log available at: $KUBEMIRROR_LOG"
log_info "Last 50 lines of controller log:"
tail -50 "$KUBEMIRROR_LOG"
return 1
fi
}
main "$@"
+1324
View File
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
#!/bin/bash
# KubeMirror E2E Test Framework
# Provides reusable test functions for comprehensive scenario testing
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
# Test scenario execution framework
# Create a source resource (Secret or ConfigMap)
create_source() {
local resource_type=$1
local resource_name=$2
local namespace=${3:-"default"}
local has_enabled_label=${4:-"false"}
local has_sync_annotation=${5:-"false"}
local target_namespaces=${6:-""}
local data_content=${7:-"test-data-123"}
log_info "Creating $resource_type/$resource_name in namespace $namespace"
log_info " enabled_label=$has_enabled_label, sync_annotation=$has_sync_annotation"
log_info " target_namespaces='$target_namespaces'"
local labels=""
if [ "$has_enabled_label" = "true" ]; then
labels='kubemirror.raczylo.com/enabled: "true"'
fi
local annotations=""
if [ "$has_sync_annotation" = "true" ]; then
annotations='kubemirror.raczylo.com/sync: "true"'
if [ -n "$target_namespaces" ]; then
annotations="$annotations
kubemirror.raczylo.com/target-namespaces: \"$target_namespaces\""
fi
fi
if [ "$resource_type" = "secret" ]; then
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: $resource_name
namespace: $namespace
${labels:+labels:}
${labels:+ $labels}
${annotations:+annotations:}
${annotations:+ $annotations}
type: Opaque
stringData:
testkey: "$data_content"
EOF
else # configmap
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: $resource_name
namespace: $namespace
${labels:+labels:}
${labels:+ $labels}
${annotations:+annotations:}
${annotations:+ $annotations}
data:
testkey: "$data_content"
EOF
fi
sleep 2
}
# Update source labels
update_source_labels() {
local resource_type=$1
local resource_name=$2
local namespace=$3
local enabled_label=$4
log_info "Updating $resource_type/$resource_name labels: enabled=$enabled_label"
if [ "$enabled_label" = "true" ]; then
kubectl label "$resource_type" "$resource_name" -n "$namespace" \
kubemirror.raczylo.com/enabled=true --overwrite
elif [ "$enabled_label" = "false" ]; then
kubectl label "$resource_type" "$resource_name" -n "$namespace" \
kubemirror.raczylo.com/enabled=false --overwrite
else
kubectl label "$resource_type" "$resource_name" -n "$namespace" \
kubemirror.raczylo.com/enabled- 2>/dev/null || true
fi
sleep 2
}
# Update source annotations
update_source_annotations() {
local resource_type=$1
local resource_name=$2
local namespace=$3
local sync_annotation=$4
local target_namespaces=$5
log_info "Updating $resource_type/$resource_name annotations: sync=$sync_annotation"
log_info " target_namespaces='$target_namespaces'"
if [ "$sync_annotation" = "true" ]; then
kubectl annotate "$resource_type" "$resource_name" -n "$namespace" \
kubemirror.raczylo.com/sync=true --overwrite
if [ -n "$target_namespaces" ]; then
kubectl annotate "$resource_type" "$resource_name" -n "$namespace" \
kubemirror.raczylo.com/target-namespaces="$target_namespaces" --overwrite
fi
elif [ "$sync_annotation" = "false" ]; then
kubectl annotate "$resource_type" "$resource_name" -n "$namespace" \
kubemirror.raczylo.com/sync=false --overwrite
else
kubectl annotate "$resource_type" "$resource_name" -n "$namespace" \
kubemirror.raczylo.com/sync- 2>/dev/null || true
kubectl annotate "$resource_type" "$resource_name" -n "$namespace" \
kubemirror.raczylo.com/target-namespaces- 2>/dev/null || true
fi
sleep 2
}
# Update source data content
update_source_data() {
local resource_type=$1
local resource_name=$2
local namespace=$3
local new_data=$4
log_info "Updating $resource_type/$resource_name data content"
if [ "$resource_type" = "secret" ]; then
kubectl patch secret "$resource_name" -n "$namespace" --type merge \
-p "{\"stringData\":{\"testkey\":\"$new_data\"}}"
else
kubectl patch configmap "$resource_name" -n "$namespace" --type merge \
-p "{\"data\":{\"testkey\":\"$new_data\"}}"
fi
sleep 3
}
# Create namespace with optional label
create_test_namespace() {
local namespace=$1
local allow_mirrors_label=${2:-""}
log_info "Creating namespace $namespace (allow_mirrors=$allow_mirrors_label)"
kubectl create namespace "$namespace" 2>/dev/null || true
if [ "$allow_mirrors_label" = "true" ]; then
kubectl label namespace "$namespace" kubemirror.raczylo.com/allow-mirrors=true --overwrite
elif [ "$allow_mirrors_label" = "false" ]; then
kubectl label namespace "$namespace" kubemirror.raczylo.com/allow-mirrors=false --overwrite
fi
sleep 1
}
# Update namespace labels
update_namespace_labels() {
local namespace=$1
local allow_mirrors_label=$2
log_info "Updating namespace $namespace labels: allow_mirrors=$allow_mirrors_label"
if [ "$allow_mirrors_label" = "true" ]; then
kubectl label namespace "$namespace" kubemirror.raczylo.com/allow-mirrors=true --overwrite
elif [ "$allow_mirrors_label" = "false" ]; then
kubectl label namespace "$namespace" kubemirror.raczylo.com/allow-mirrors=false --overwrite
else
kubectl label namespace "$namespace" kubemirror.raczylo.com/allow-mirrors- 2>/dev/null || true
fi
sleep 2
}
# Verify mirror exists in expected namespaces
verify_mirrors_exist() {
local resource_type=$1
local resource_name=$2
shift 2
local namespaces=("$@")
log_info "Verifying mirrors exist in ${#namespaces[@]} namespaces"
local all_ok=true
for ns in "${namespaces[@]}"; do
if wait_for_resource "$resource_type" "$resource_name" "$ns" 30; then
assert_resource_exists "$resource_type" "$resource_name" "$ns" || all_ok=false
assert_label_exists "$resource_type" "$resource_name" "$ns" "kubemirror.raczylo.com/managed-by" "kubemirror" || all_ok=false
else
log_fail "Mirror not created in $ns within timeout"
((TESTS_RUN++))
((TESTS_FAILED++))
all_ok=false
fi
done
$all_ok
}
# Verify mirror does NOT exist in specified namespaces
verify_mirrors_not_exist() {
local resource_type=$1
local resource_name=$2
shift 2
local namespaces=("$@")
log_info "Verifying mirrors DO NOT exist in ${#namespaces[@]} namespaces"
local all_ok=true
for ns in "${namespaces[@]}"; do
assert_resource_not_exists "$resource_type" "$resource_name" "$ns" || all_ok=false
done
$all_ok
}
# Verify mirror data matches source
verify_mirror_data() {
local resource_type=$1
local source_name=$2
local source_ns=$3
local target_ns=$4
local expected_data=$5
local actual_data
if [ "$resource_type" = "secret" ]; then
actual_data=$(kubectl get secret "$source_name" -n "$target_ns" -o jsonpath='{.data.testkey}' 2>/dev/null | base64 -d || echo "")
else
actual_data=$(kubectl get configmap "$source_name" -n "$target_ns" -o jsonpath='{.data.testkey}' 2>/dev/null || echo "")
fi
((TESTS_RUN++))
if [ "$actual_data" = "$expected_data" ]; then
log_success "Mirror data in $target_ns matches expected: $expected_data"
((TESTS_PASSED++))
return 0
else
log_fail "Mirror data in $target_ns does NOT match (expected: $expected_data, actual: $actual_data)"
((TESTS_FAILED++))
return 1
fi
}
# Verify orphaned mirrors are cleaned up
verify_orphan_cleanup() {
local resource_type=$1
local resource_name=$2
shift 2
local orphaned_namespaces=("$@")
log_info "Verifying orphaned mirrors cleaned up in ${#orphaned_namespaces[@]} namespaces"
local all_ok=true
for ns in "${orphaned_namespaces[@]}"; do
if wait_for_resource_deletion "$resource_type" "$resource_name" "$ns" 30; then
assert_resource_not_exists "$resource_type" "$resource_name" "$ns" || all_ok=false
else
log_fail "Orphaned mirror in $ns not deleted within timeout"
((TESTS_RUN++))
((TESTS_FAILED++))
all_ok=false
fi
done
$all_ok
}
# Delete namespace
delete_namespace() {
local namespace=$1
log_info "Deleting namespace $namespace"
kubectl delete namespace "$namespace" --ignore-not-found=true &
sleep 2
}
# Test scenario runner
run_test_scenario() {
local scenario_name=$1
echo ""
echo "======================================"
echo "Scenario: $scenario_name"
echo "======================================"
}
# Complete scenario runner
complete_test_scenario() {
local scenario_name=$1
local result=$2
if [ "$result" = "pass" ]; then
log_success "Scenario '$scenario_name' completed successfully"
else
log_fail "Scenario '$scenario_name' failed"
fi
}
+207
View File
@@ -0,0 +1,207 @@
#!/bin/bash
# Parallel E2E Test Runner for KubeMirror
# Runs independent tests in parallel batches for faster execution
#
# Usage:
# ./test-parallel.sh # Run all tests in parallel batches
#
# Performance:
# - Sequential execution: ~5-7 minutes for all 30 scenarios
# - Parallel execution: ~3-4 minutes (batched by independence)
#
# Batching Strategy:
# - Sequential: Scenarios 1-11 (core lifecycle - must run sequentially)
# - Parallel Batch 1: Scenarios 12-15 (namespace labels)
# - Parallel Batch 2: Scenarios 16-19 (deletion scenarios)
# - Parallel Batch 3: Scenarios 20-23 (mixed resources)
# - Parallel Batch 4: Scenarios 24-27 (transformations part 1)
# - Parallel Batch 5: Scenarios 28-30 (transformations part 2)
#
# Individual scenario logs: e2e/.test-scenario-{num}.log
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
TEST_NAME="Parallel E2E Tests"
# Colors
CYAN='\033[0;36m'
NC='\033[0m'
# Batch execution tracking
BATCH_RESULTS=()
TOTAL_START_TIME=$(date +%s)
# Run a single scenario by number
run_scenario() {
local scenario_num=$1
local log_file="$SCRIPT_DIR/.test-scenario-${scenario_num}.log"
echo -e "${CYAN}[BATCH]${NC} Starting scenario $scenario_num in background"
# Run the specific scenario using the comprehensive test script
# Pass scenario number as argument to run only that scenario
bash "$SCRIPT_DIR/test-comprehensive.sh" "$scenario_num" > "$log_file" 2>&1
local exit_code=$?
# Store result
echo "$scenario_num:$exit_code" >> "$SCRIPT_DIR/.batch-results.tmp"
if [ $exit_code -eq 0 ]; then
echo -e "${GREEN}[PASS]${NC} Scenario $scenario_num completed successfully"
else
echo -e "${RED}[FAIL]${NC} Scenario $scenario_num failed (exit code: $exit_code)"
fi
return $exit_code
}
# Run multiple scenarios in parallel
run_parallel_batch() {
local batch_name=$1
shift
local scenarios=("$@")
echo ""
echo -e "${CYAN}======================================${NC}"
echo -e "${CYAN}Batch: $batch_name${NC}"
echo -e "${CYAN}Scenarios: ${scenarios[*]}${NC}"
echo -e "${CYAN}======================================${NC}"
local batch_start=$(date +%s)
# Clear batch results file
> "$SCRIPT_DIR/.batch-results.tmp"
# Start all scenarios in background
local pids=()
for scenario in "${scenarios[@]}"; do
run_scenario "$scenario" &
pids+=($!)
done
# Wait for all to complete
local batch_failed=0
for pid in "${pids[@]}"; do
wait $pid || batch_failed=1
done
local batch_end=$(date +%s)
local batch_duration=$((batch_end - batch_start))
echo -e "${CYAN}Batch '$batch_name' completed in ${batch_duration}s${NC}"
return $batch_failed
}
# Main execution
main() {
log_info "Starting $TEST_NAME"
log_info "This runner executes independent tests in parallel for faster results"
# Ensure controller is running
if ! pgrep -f "kubemirror" > /dev/null; then
log_fail "KubeMirror controller is not running!"
log_info "Please start the controller first: ./e2e/run-all-tests.sh"
exit 1
fi
# Clean up any previous test artifacts
log_info "Cleaning up previous test artifacts"
rm -f "$SCRIPT_DIR/.test-scenario-"*.log
rm -f "$SCRIPT_DIR/.batch-results.tmp"
local overall_failed=0
# ========================================================================
# SEQUENTIAL BATCH: Core Lifecycle (Scenarios 1-11)
# These tests modify the same resource and MUST run sequentially
# ========================================================================
echo ""
echo -e "${CYAN}======================================${NC}"
echo -e "${CYAN}Sequential Batch: Core Lifecycle${NC}"
echo -e "${CYAN}Scenarios: 1-11 (sequential execution required)${NC}"
echo -e "${CYAN}======================================${NC}"
local seq_start=$(date +%s)
bash "$SCRIPT_DIR/test-comprehensive.sh" 1 2 3 4 5 6 7 8 9 10 11
if [ $? -ne 0 ]; then
overall_failed=1
log_fail "Sequential batch (1-11) failed"
fi
local seq_end=$(date +%s)
echo -e "${CYAN}Sequential batch completed in $((seq_end - seq_start))s${NC}"
# ========================================================================
# PARALLEL BATCH 1: Namespace Label Tests (Scenarios 12-15)
# ========================================================================
run_parallel_batch "Namespace Labels" 12 13 14 15
[ $? -ne 0 ] && overall_failed=1
# ========================================================================
# PARALLEL BATCH 2: Deletion Scenarios (Scenarios 16-19)
# ========================================================================
run_parallel_batch "Deletion Scenarios" 16 17 18 19
[ $? -ne 0 ] && overall_failed=1
# ========================================================================
# PARALLEL BATCH 3: Mixed Resources (Scenarios 20-23)
# ========================================================================
run_parallel_batch "Mixed Resources" 20 21 22 23
[ $? -ne 0 ] && overall_failed=1
# ========================================================================
# PARALLEL BATCH 4: Transformations Part 1 (Scenarios 24-27)
# ========================================================================
run_parallel_batch "Transformations 1" 24 25 26 27
[ $? -ne 0 ] && overall_failed=1
# ========================================================================
# PARALLEL BATCH 5: Transformations Part 2 (Scenarios 28-30)
# ========================================================================
run_parallel_batch "Transformations 2" 28 29 30
[ $? -ne 0 ] && overall_failed=1
# ========================================================================
# SUMMARY
# ========================================================================
local total_end=$(date +%s)
local total_duration=$((total_end - TOTAL_START_TIME))
echo ""
echo -e "${CYAN}======================================${NC}"
echo -e "${CYAN}Test Execution Summary${NC}"
echo -e "${CYAN}======================================${NC}"
echo -e "Total execution time: ${total_duration}s"
echo -e "Sequential scenarios: 1-11"
echo -e "Parallel batches: 5 batches (scenarios 12-30)"
if [ $overall_failed -eq 0 ]; then
echo -e "${GREEN}All test batches PASSED!${NC}"
# Show individual scenario results from logs
echo ""
echo "Individual scenario results:"
for i in {1..30}; do
if [ -f "$SCRIPT_DIR/.test-scenario-${i}.log" ]; then
if grep -q "PASS.*Scenario.*${i}" "$SCRIPT_DIR/.test-scenario-${i}.log" 2>/dev/null; then
echo -e " Scenario $i: ${GREEN}PASS${NC}"
else
echo -e " Scenario $i: ${RED}FAIL${NC}"
fi
fi
done
else
echo -e "${RED}Some test batches FAILED!${NC}"
echo ""
echo "Check individual scenario logs in: $SCRIPT_DIR/.test-scenario-*.log"
fi
# Cleanup temp files
rm -f "$SCRIPT_DIR/.batch-results.tmp"
exit $overall_failed
}
main "$@"
+4
View File
@@ -50,6 +50,10 @@ type Config struct {
EnableAllKeyword bool EnableAllKeyword bool
// DryRun mode logs what would happen without actually making changes // DryRun mode logs what would happen without actually making changes
DryRun bool DryRun bool
// VerifySourceFreshness checks cache staleness and re-fetches from API if needed
// Prevents mirroring stale data when cache hasn't updated yet after watch event
// Trades some API load for guaranteed data freshness
VerifySourceFreshness bool
} }
// LeaderElectionConfig holds leader election settings. // LeaderElectionConfig holds leader election settings.
+8
View File
@@ -84,6 +84,14 @@ const (
// AnnotationDeletionAttempts tracks number of failed deletion attempts. // AnnotationDeletionAttempts tracks number of failed deletion attempts.
AnnotationDeletionAttempts = Domain + "/deletion-attempts" AnnotationDeletionAttempts = Domain + "/deletion-attempts"
// Transformation Annotations
// AnnotationTransform contains YAML transformation rules for mirrored resources.
AnnotationTransform = Domain + "/transform"
// AnnotationTransformStrict enables strict mode (transformation errors block mirroring).
AnnotationTransformStrict = Domain + "/transform-strict"
// Finalizers // Finalizers
// FinalizerName is the finalizer added to source resources. // FinalizerName is the finalizer added to source resources.
+75 -8
View File
@@ -302,18 +302,42 @@ func updateMirrorAnnotations(mirror metav1.Object, source runtime.Object, source
} }
// updateUnstructuredMirror updates an unstructured mirror. // updateUnstructuredMirror updates an unstructured mirror.
// Uses generic field introspection to handle any resource type (Secrets, ConfigMaps, CRDs).
func updateUnstructuredMirror(mirror, source runtime.Object, sourceHash string) error { func updateUnstructuredMirror(mirror, source runtime.Object, sourceHash string) error {
m := mirror.(*unstructured.Unstructured) m := mirror.(*unstructured.Unstructured)
s := source.(*unstructured.Unstructured) s := source.(*unstructured.Unstructured)
// Update spec // Fields to skip (Kubernetes-managed fields, not user content)
sourceSpec, found, err := unstructured.NestedMap(s.Object, "spec") // These are managed by Kubernetes API server or controllers
if err != nil { skipFields := map[string]bool{
return fmt.Errorf("failed to get source spec: %w", err) // Standard Kubernetes top-level fields
"metadata": true, // Kubernetes metadata (name, namespace, labels, etc.) - managed separately
"status": true, // Resource status - managed by controllers, never mirrored
"apiVersion": true, // API group version - static, set during creation
"kind": true, // Resource kind - static, set during creation
// Kubernetes internal fields (rarely at top level, but be defensive)
"managedFields": true, // Field management tracking - internal to Kubernetes
"selfLink": true, // Deprecated but might exist - auto-generated
"resourceVersion": true, // Optimistic concurrency control - auto-generated
"generation": true, // Spec change counter - auto-generated (but usually in metadata)
"creationTimestamp": true, // Resource creation time - auto-generated (but usually in metadata)
"deletionTimestamp": true, // Resource deletion time - auto-generated (but usually in metadata)
"deletionGracePeriodSeconds": true, // Grace period - auto-managed (but usually in metadata)
"uid": true, // Unique identifier - auto-generated (but usually in metadata)
"ownerReferences": true, // Ownership chain - should not be copied (but usually in metadata)
"finalizers": true, // Deletion hooks - should not be copied (but usually in metadata)
} }
if found {
if err := unstructured.SetNestedMap(m.Object, sourceSpec, "spec"); err != nil { // Copy all content fields from source to mirror
return fmt.Errorf("failed to set mirror spec: %w", err) // This handles:
// - .spec (standard CRDs like Traefik Middleware)
// - .data, .type (Secrets)
// - .data, .binaryData (ConfigMaps)
// - Any custom top-level fields in non-standard CRDs
for key, value := range s.Object {
if !skipFields[key] {
m.Object[key] = value
} }
} }
@@ -369,18 +393,61 @@ func GetSourceReference(mirror metav1.Object) (namespace, name, uid string, foun
// applyTransformations applies transformation rules from the source to the mirror. // applyTransformations applies transformation rules from the source to the mirror.
// Returns the transformed mirror, or the original mirror if no rules are present. // Returns the transformed mirror, or the original mirror if no rules are present.
func applyTransformations(source, mirror runtime.Object, targetNamespace string) (runtime.Object, error) { func applyTransformations(source, mirror runtime.Object, targetNamespace string) (runtime.Object, error) {
// Get source annotations to check for transform rules
sourceObj, ok := source.(metav1.Object)
if !ok {
return mirror, nil
}
sourceAnnotations := sourceObj.GetAnnotations()
if sourceAnnotations == nil {
return mirror, nil
}
transformRules, hasTransform := sourceAnnotations[constants.AnnotationTransform]
if !hasTransform || transformRules == "" {
return mirror, nil // No transformation rules
}
// Temporarily copy transform annotations to mirror for Transform to read
// The Transform function reads rules from the object being transformed
mirrorObj, ok := mirror.(metav1.Object)
if !ok {
return mirror, nil
}
mirrorAnnotations := mirrorObj.GetAnnotations()
if mirrorAnnotations == nil {
mirrorAnnotations = make(map[string]string)
}
// Copy transform annotations from source
mirrorAnnotations[constants.AnnotationTransform] = transformRules
if strictMode, hasStrict := sourceAnnotations[constants.AnnotationTransformStrict]; hasStrict {
mirrorAnnotations[constants.AnnotationTransformStrict] = strictMode
}
mirrorObj.SetAnnotations(mirrorAnnotations)
// Build transformation context // Build transformation context
ctx := buildTransformContext(source, mirror, targetNamespace) ctx := buildTransformContext(source, mirror, targetNamespace)
// Create transformer with default options // Create transformer with default options
t := transformer.NewDefaultTransformer() t := transformer.NewDefaultTransformer()
// Apply transformations (transformer handles case of no rules gracefully) // Apply transformations (transformer reads rules from mirror's annotations now)
transformed, err := t.Transform(mirror, ctx) transformed, err := t.Transform(mirror, ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Remove transform annotations from result (they shouldn't persist on mirrors)
if transformedObj, ok := transformed.(metav1.Object); ok {
annotations := transformedObj.GetAnnotations()
delete(annotations, constants.AnnotationTransform)
delete(annotations, constants.AnnotationTransformStrict)
transformedObj.SetAnnotations(annotations)
}
return transformed, nil return transformed, nil
} }
+152
View File
@@ -0,0 +1,152 @@
package controller
import (
"context"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
)
// MirrorReconciler reconciles mirrored resources to detect and clean up orphans.
// This reconciler watches resources with the managed-by label and verifies their source still exists.
type MirrorReconciler struct {
client.Client
Scheme *runtime.Scheme
GVK schema.GroupVersionKind // The resource type this reconciler handles
}
// Reconcile checks if a mirrored resource's source still exists, and deletes the mirror if orphaned.
func (r *MirrorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// Fetch the mirror resource
mirror := &unstructured.Unstructured{}
gv := schema.GroupVersion{Group: r.GVK.Group, Version: r.GVK.Version}
mirror.SetGroupVersionKind(gv.WithKind(r.GVK.Kind))
if err := r.Get(ctx, req.NamespacedName, mirror); err != nil {
// Mirror already deleted or doesn't exist - nothing to do
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Extract annotations using unstructured helper methods
annotations := mirror.GetAnnotations()
if annotations == nil {
// No annotations - not a valid mirror, skip
return ctrl.Result{}, nil
}
// Extract source reference from annotations
sourceNs, hasSourceNs := annotations[constants.AnnotationSourceNamespace]
sourceName, hasSourceName := annotations[constants.AnnotationSourceName]
sourceUID, hasSourceUID := annotations[constants.AnnotationSourceUID]
if !hasSourceNs || !hasSourceName || !hasSourceUID {
// Missing source reference annotations - not a valid mirror or corrupted
logger.V(1).Info("mirror missing source reference annotations, skipping",
"namespace", req.Namespace, "name", req.Name)
return ctrl.Result{}, nil
}
// Try to fetch the source resource
source := &unstructured.Unstructured{}
source.SetGroupVersionKind(gv.WithKind(r.GVK.Kind))
sourceKey := types.NamespacedName{
Namespace: sourceNs,
Name: sourceName,
}
err := r.Get(ctx, sourceKey, source)
if err != nil {
if client.IgnoreNotFound(err) == nil {
// Source not found - this is an orphaned mirror, delete it
logger.Info("orphaned mirror detected (source deleted), cleaning up",
"mirror", req.NamespacedName,
"sourceNamespace", sourceNs,
"sourceName", sourceName,
"sourceUID", sourceUID)
if err := r.Delete(ctx, mirror); err != nil {
logger.Error(err, "failed to delete orphaned mirror")
return ctrl.Result{}, err
}
logger.Info("orphaned mirror deleted successfully",
"mirror", req.NamespacedName,
"sourceNamespace", sourceNs,
"sourceName", sourceName)
return ctrl.Result{}, nil
}
// Some other error fetching source
logger.Error(err, "failed to fetch source resource for mirror",
"sourceNamespace", sourceNs, "sourceName", sourceName)
return ctrl.Result{}, err
}
// Source exists - verify UID matches
actualUID := string(source.GetUID())
if actualUID != sourceUID {
// Source was recreated with different UID - this is a stale mirror
logger.Info("stale mirror detected (source recreated with different UID), cleaning up",
"mirror", req.NamespacedName,
"sourceNamespace", sourceNs,
"sourceName", sourceName,
"expectedUID", sourceUID,
"actualUID", actualUID)
if err := r.Delete(ctx, mirror); err != nil {
logger.Error(err, "failed to delete stale mirror")
return ctrl.Result{}, err
}
logger.Info("stale mirror deleted successfully",
"mirror", req.NamespacedName,
"sourceNamespace", sourceNs,
"sourceName", sourceName)
return ctrl.Result{}, nil
}
// Source exists and UID matches - mirror is valid
logger.V(1).Info("mirror source verified",
"mirror", req.NamespacedName,
"sourceNamespace", sourceNs,
"sourceName", sourceName)
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *MirrorReconciler) SetupWithManager(mgr ctrl.Manager, gvk schema.GroupVersionKind) error {
// Create a predicate that only watches resources with the managed-by label
managedByPredicate := predicate.NewPredicateFuncs(func(obj client.Object) bool {
labels := obj.GetLabels()
if labels == nil {
return false
}
managedBy, exists := labels[constants.LabelManagedBy]
return exists && managedBy == "kubemirror"
})
// Convert GVK to resource object for watching
obj := &unstructured.Unstructured{}
gv := schema.GroupVersion{Group: gvk.Group, Version: gvk.Version}
obj.SetGroupVersionKind(gv.WithKind(gvk.Kind))
// Set custom controller name to avoid conflicts with source reconciler and multiple API versions
// Include group and version to make it truly unique
controllerName := gvk.Kind + "." + gvk.Version + "." + gvk.Group + "-mirror"
return ctrl.NewControllerManagedBy(mgr).
For(obj).
Named(controllerName).
WithEventFilter(managedByPredicate).
Complete(r)
}
+124
View File
@@ -368,6 +368,130 @@ func TestUpdateMirror_ConfigMap(t *testing.T) {
assert.NotEqual(t, "oldhash", mirror.Annotations[constants.AnnotationSourceContentHash]) assert.NotEqual(t, "oldhash", mirror.Annotations[constants.AnnotationSourceContentHash])
} }
func TestUpdateMirror_UnstructuredSecret(t *testing.T) {
// This test validates the fix for the bug where Unstructured Secrets
// would update annotations but not data fields during UpdateMirror
mirror := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "app1",
"labels": map[string]interface{}{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
"annotations": map[string]interface{}{
constants.AnnotationSourceContentHash: "oldhash",
},
},
"type": "Opaque",
"data": map[string]interface{}{
"password": "b2xkLXZhbHVl", // base64 encoded "old-value"
},
},
}
source := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "default",
"generation": int64(10),
},
"type": "kubernetes.io/tls",
"data": map[string]interface{}{
"password": "bmV3LXZhbHVl", // base64 encoded "new-value"
"username": "YWRtaW4=", // base64 encoded "admin"
},
},
}
err := UpdateMirror(mirror, source)
require.NoError(t, err)
// Verify data was updated (this was the bug - data wasn't being updated)
mirrorData, found, err := unstructured.NestedMap(mirror.Object, "data")
require.NoError(t, err)
require.True(t, found, "mirror should have data field")
sourceData, _, _ := unstructured.NestedMap(source.Object, "data")
assert.Equal(t, sourceData, mirrorData, "mirror data should match source data")
// Verify type was updated
mirrorType, found, err := unstructured.NestedString(mirror.Object, "type")
require.NoError(t, err)
require.True(t, found, "mirror should have type field")
assert.Equal(t, "kubernetes.io/tls", mirrorType, "mirror type should be updated")
// Verify annotations were updated
annotations := mirror.GetAnnotations()
assert.NotEqual(t, "oldhash", annotations[constants.AnnotationSourceContentHash], "hash should be updated")
assert.Equal(t, "10", annotations[constants.AnnotationSourceGeneration], "generation should be updated")
assert.NotEmpty(t, annotations[constants.AnnotationLastSyncTime], "sync time should be set")
}
func TestUpdateMirror_UnstructuredConfigMap(t *testing.T) {
// Test Unstructured ConfigMap to ensure data and binaryData are updated
mirror := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-config",
"namespace": "app1",
"annotations": map[string]interface{}{
constants.AnnotationSourceContentHash: "oldhash",
},
},
"data": map[string]interface{}{
"key": "old-value",
},
},
}
source := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-config",
"namespace": "default",
},
"data": map[string]interface{}{
"key": "new-value",
"key2": "another-value",
},
"binaryData": map[string]interface{}{
"binary": "AAECAwQ=", // base64 binary data
},
},
}
err := UpdateMirror(mirror, source)
require.NoError(t, err)
// Verify data was updated
mirrorData, found, err := unstructured.NestedMap(mirror.Object, "data")
require.NoError(t, err)
require.True(t, found, "mirror should have data field")
sourceData, _, _ := unstructured.NestedMap(source.Object, "data")
assert.Equal(t, sourceData, mirrorData, "mirror data should match source data")
// Verify binaryData was updated
mirrorBinaryData, found, err := unstructured.NestedMap(mirror.Object, "binaryData")
require.NoError(t, err)
require.True(t, found, "mirror should have binaryData field")
sourceBinaryData, _, _ := unstructured.NestedMap(source.Object, "binaryData")
assert.Equal(t, sourceBinaryData, mirrorBinaryData, "mirror binaryData should match source binaryData")
// Verify annotations were updated
annotations := mirror.GetAnnotations()
assert.NotEqual(t, "oldhash", annotations[constants.AnnotationSourceContentHash], "hash should be updated")
}
func TestIsManagedByUs(t *testing.T) { func TestIsManagedByUs(t *testing.T) {
tests := []struct { tests := []struct {
obj metav1.Object obj metav1.Object
+20
View File
@@ -55,3 +55,23 @@ func (k *KubernetesNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Conte
return names, nil return names, nil
} }
// ListOptOutNamespaces returns namespaces that have explicitly opted out of mirrors.
// These are namespaces with allow-mirrors="false".
func (k *KubernetesNamespaceLister) ListOptOutNamespaces(ctx context.Context) ([]string, error) {
namespaceList := &corev1.NamespaceList{}
// List namespaces with allow-mirrors label set to false
if err := k.client.List(ctx, namespaceList, client.MatchingLabels{
constants.LabelAllowMirrors: "false",
}); err != nil {
return nil, err
}
names := make([]string, 0, len(namespaceList.Items))
for _, ns := range namespaceList.Items {
names = append(names, ns.Name)
}
return names, nil
}
+310
View File
@@ -0,0 +1,310 @@
// Package controller implements the kubemirror reconciliation logic.
package controller
import (
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
)
// NamespaceReconciler watches for namespace CREATE and UPDATE events
// and triggers reconciliation of source resources that match the new namespace.
type NamespaceReconciler struct {
client.Client
Scheme *runtime.Scheme
Config *config.Config
Filter *filter.NamespaceFilter
NamespaceLister NamespaceLister
// ResourceTypes contains all discovered resource types to reconcile
ResourceTypes []config.ResourceType
}
// Reconcile processes namespace events and creates mirrors for matching sources.
func (r *NamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx).WithValues("namespace", req.Name)
// Fetch the namespace
namespace := &corev1.Namespace{}
if err := r.Get(ctx, req.NamespacedName, namespace); err != nil {
// Namespace was deleted - nothing to do (source reconcilers will handle cleanup)
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Skip system namespaces
if r.Filter != nil && !r.Filter.IsAllowed(namespace.Name) {
logger.V(1).Info("namespace filtered out, skipping")
return ctrl.Result{}, nil
}
logger.Info("namespace event detected, reconciling source resources")
// Query all source resources that have mirroring enabled
// For each resource type, find resources with the sync annotation
var totalReconciled, totalErrors int
for _, rt := range r.ResourceTypes {
reconciled, errors, err := r.reconcileResourceType(ctx, rt, namespace.Name)
if err != nil {
logger.Error(err, "failed to reconcile resource type",
"group", rt.Group, "version", rt.Version, "kind", rt.Kind)
totalErrors++
continue
}
totalReconciled += reconciled
totalErrors += errors
}
logger.Info("namespace reconciliation complete",
"reconciled", totalReconciled,
"errors", totalErrors,
"resourceTypes", len(r.ResourceTypes))
if totalErrors > 0 {
return ctrl.Result{}, fmt.Errorf("failed to reconcile %d source resources", totalErrors)
}
return ctrl.Result{}, nil
}
// reconcileResourceType finds and reconciles all sources of a specific resource type
// that match the namespace.
func (r *NamespaceReconciler) reconcileResourceType(ctx context.Context, rt config.ResourceType, namespaceName string) (int, int, error) {
logger := log.FromContext(ctx)
gvk := rt.GroupVersionKind()
// List all resources of this type with the enabled label
// Using label selector for server-side filtering
list := &unstructured.UnstructuredList{}
list.SetGroupVersionKind(gvk)
listOpts := []client.ListOption{
client.HasLabels{constants.LabelEnabled},
}
if err := r.List(ctx, list, listOpts...); err != nil {
return 0, 0, fmt.Errorf("failed to list resources: %w", err)
}
var reconciledCount, errorCount int
for i := range list.Items {
source := &list.Items[i]
// Check if source has sync annotation
annotations := source.GetAnnotations()
if annotations == nil || annotations[constants.AnnotationSync] != "true" {
continue
}
// Skip if this is a mirror resource itself
if IsMirrorResource(source) {
continue
}
// Resolve target namespaces for this source
targetNamespaces, err := r.resolveTargetNamespaces(ctx, source)
if err != nil {
logger.Error(err, "failed to resolve target namespaces",
"source", source.GetName(), "namespace", source.GetNamespace())
errorCount++
continue
}
// Check if the new namespace matches this source's targets
var isTarget bool
for _, target := range targetNamespaces {
if target == namespaceName {
isTarget = true
break
}
}
if isTarget {
// Create or update mirror in the namespace
if err := r.reconcileMirror(ctx, source, namespaceName); err != nil {
logger.Error(err, "failed to create mirror",
"source", source.GetName(),
"sourceNamespace", source.GetNamespace(),
"targetNamespace", namespaceName)
errorCount++
continue
}
reconciledCount++
logger.V(1).Info("mirror created/updated for namespace",
"source", source.GetName(),
"sourceNamespace", source.GetNamespace(),
"targetNamespace", namespaceName,
"resourceType", rt.String())
} else {
// Namespace is no longer a target - check if mirror exists and delete it
mirror := &unstructured.Unstructured{}
mirror.SetGroupVersionKind(source.GroupVersionKind())
mirror.SetNamespace(namespaceName)
mirror.SetName(source.GetName())
err := r.Get(ctx, client.ObjectKey{Namespace: namespaceName, Name: source.GetName()}, mirror)
if errors.IsNotFound(err) {
// No mirror exists, nothing to clean up
continue
}
if err != nil {
logger.Error(err, "failed to check for mirror",
"source", source.GetName(),
"namespace", namespaceName)
errorCount++
continue
}
// Verify this is actually our mirror (not someone else's resource with the same name)
if !IsManagedByUs(mirror) {
continue
}
// Verify this mirror points to our source
srcNs, srcName, _, found := GetSourceReference(mirror)
if !found || srcNs != source.GetNamespace() || srcName != source.GetName() {
continue
}
// This mirror should be deleted (namespace no longer a valid target)
if err := r.Delete(ctx, mirror); err != nil {
logger.Error(err, "failed to delete orphaned mirror",
"source", source.GetName(),
"sourceNamespace", source.GetNamespace(),
"targetNamespace", namespaceName)
errorCount++
continue
}
reconciledCount++
logger.V(1).Info("deleted orphaned mirror due to namespace label change",
"source", source.GetName(),
"sourceNamespace", source.GetNamespace(),
"targetNamespace", namespaceName,
"resourceType", rt.String())
}
}
return reconciledCount, errorCount, nil
}
// resolveTargetNamespaces determines which namespaces should receive mirrors for a source.
// Uses the same logic as SourceReconciler.resolveTargetNamespaces.
func (r *NamespaceReconciler) resolveTargetNamespaces(ctx context.Context, source *unstructured.Unstructured) ([]string, error) {
annotations := source.GetAnnotations()
if annotations == nil {
return nil, nil
}
targetNsAnnotation := annotations[constants.AnnotationTargetNamespaces]
if targetNsAnnotation == "" {
return nil, nil
}
// Parse patterns
patterns := filter.ParseTargetNamespaces(targetNsAnnotation)
if len(patterns) == 0 {
return nil, nil
}
// Get all namespaces
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list namespaces: %w", err)
}
// Get namespaces with allow-mirrors label
allowMirrorsNamespaces, err := r.NamespaceLister.ListAllowMirrorsNamespaces(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list allow-mirrors namespaces: %w", err)
}
// Get namespaces that have explicitly opted out (allow-mirrors="false")
optOutNamespaces, err := r.NamespaceLister.ListOptOutNamespaces(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list opt-out namespaces: %w", err)
}
// Resolve target namespaces
targetNamespaces := filter.ResolveTargetNamespaces(
patterns,
allNamespaces,
allowMirrorsNamespaces,
optOutNamespaces,
source.GetNamespace(),
r.Filter,
)
// Enforce max targets limit
if r.Config != nil && r.Config.MaxTargetsPerResource > 0 && len(targetNamespaces) > r.Config.MaxTargetsPerResource {
targetNamespaces = targetNamespaces[:r.Config.MaxTargetsPerResource]
}
return targetNamespaces, nil
}
// reconcileMirror creates or updates a mirror in the target namespace.
// This calls the mirror creation logic from the SourceReconciler.
func (r *NamespaceReconciler) reconcileMirror(ctx context.Context, source *unstructured.Unstructured, targetNamespace string) error {
// Create a temporary SourceReconciler to use its mirror creation logic
// This avoids code duplication
sourceReconciler := &SourceReconciler{
Client: r.Client,
Scheme: r.Scheme,
Config: r.Config,
Filter: r.Filter,
NamespaceLister: r.NamespaceLister,
GVK: source.GroupVersionKind(),
}
return sourceReconciler.reconcileMirror(ctx, source, source, targetNamespace)
}
// SetupWithManager sets up the controller with the Manager.
func (r *NamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error {
// Create predicate to only watch for relevant namespace events
namespacePredicate := predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool {
// Always reconcile new namespaces
return true
},
UpdateFunc: func(e event.UpdateEvent) bool {
// Only reconcile if labels changed (specifically allow-mirrors label)
oldNs, okOld := e.ObjectOld.(*corev1.Namespace)
newNs, okNew := e.ObjectNew.(*corev1.Namespace)
if !okOld || !okNew {
return false
}
// Check if allow-mirrors label changed
oldLabel := oldNs.Labels[constants.LabelAllowMirrors]
newLabel := newNs.Labels[constants.LabelAllowMirrors]
return oldLabel != newLabel
},
DeleteFunc: func(e event.DeleteEvent) bool {
// Don't reconcile on delete - source reconcilers will handle cleanup via finalizers
return false
},
}
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.Namespace{}).
WithEventFilter(namespacePredicate).
Complete(r)
}
+312
View File
@@ -0,0 +1,312 @@
package controller
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
ctrl "sigs.k8s.io/controller-runtime"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
)
func TestNamespaceReconciler_CleanupWhenNamespaceNoLongerTarget(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
tests := []struct {
name string
namespace *corev1.Namespace
sourceResources []*unstructured.Unstructured
existingMirrors []*unstructured.Unstructured
expectedDeleted []string // mirror names that should be deleted
expectedRemaining []string // mirror names that should remain
}{
{
name: "namespace label changes to allow-mirrors=false, mirror should be deleted",
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "target-ns",
Labels: map[string]string{
constants.LabelAllowMirrors: "false", // Changed to false
},
},
},
sourceResources: []*unstructured.Unstructured{
makeUnstructuredSecret("test-secret", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "all",
}),
},
existingMirrors: []*unstructured.Unstructured{
makeUnstructuredMirror("test-secret", "target-ns", "default", "test-secret"),
},
expectedDeleted: []string{"test-secret"},
expectedRemaining: []string{},
},
{
name: "namespace no longer matches pattern, mirror should be deleted",
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "staging-1",
},
},
sourceResources: []*unstructured.Unstructured{
makeUnstructuredSecret("test-secret", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "prod-*", // Pattern changed, no longer matches staging-*
}),
},
existingMirrors: []*unstructured.Unstructured{
makeUnstructuredMirror("test-secret", "staging-1", "default", "test-secret"),
},
expectedDeleted: []string{"test-secret"},
expectedRemaining: []string{},
},
{
name: "namespace becomes valid target, no existing mirror, should be created",
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "prod-1",
},
},
sourceResources: []*unstructured.Unstructured{
makeUnstructuredSecret("test-secret", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "prod-*",
}),
},
existingMirrors: []*unstructured.Unstructured{},
expectedDeleted: []string{},
expectedRemaining: []string{"test-secret"}, // Should be created
},
{
name: "namespace still valid, mirror remains",
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "prod-1",
},
},
sourceResources: []*unstructured.Unstructured{
makeUnstructuredSecret("test-secret", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "prod-*",
}),
},
existingMirrors: []*unstructured.Unstructured{
makeUnstructuredMirror("test-secret", "prod-1", "default", "test-secret"),
},
expectedDeleted: []string{},
expectedRemaining: []string{"test-secret"},
},
{
name: "multiple sources, only non-matching mirrors deleted",
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "app-1",
},
},
sourceResources: []*unstructured.Unstructured{
makeUnstructuredSecret("secret-1", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "app-*", // Matches
}),
makeUnstructuredSecret("secret-2", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "prod-*", // Doesn't match
}),
},
existingMirrors: []*unstructured.Unstructured{
makeUnstructuredMirror("secret-1", "app-1", "default", "secret-1"),
makeUnstructuredMirror("secret-2", "app-1", "default", "secret-2"),
},
expectedDeleted: []string{"secret-2"},
expectedRemaining: []string{"secret-1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create fake client with namespace, sources, and existing mirrors
objects := []client.Object{tt.namespace}
for _, src := range tt.sourceResources {
objects = append(objects, src)
}
for _, mirror := range tt.existingMirrors {
objects = append(objects, mirror)
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(objects...).
Build()
// Create namespace lister mock
mockLister := &mockNamespaceLister{
namespaces: []string{tt.namespace.Name},
allowMirrors: func() map[string]bool {
result := make(map[string]bool)
if tt.namespace.Labels[constants.LabelAllowMirrors] == "true" {
result[tt.namespace.Name] = true
}
return result
}(),
optOut: func() map[string]bool {
result := make(map[string]bool)
if tt.namespace.Labels[constants.LabelAllowMirrors] == "false" {
result[tt.namespace.Name] = true
}
return result
}(),
}
// Create reconciler
reconciler := &NamespaceReconciler{
Client: fakeClient,
Scheme: scheme,
Config: &config.Config{MaxTargetsPerResource: 100},
Filter: filter.NewNamespaceFilter([]string{"kube-system"}, []string{}),
NamespaceLister: mockLister,
ResourceTypes: []config.ResourceType{
{Group: "", Version: "v1", Kind: "Secret"},
},
}
// Reconcile the namespace
ctx := context.Background()
req := ctrl.Request{
NamespacedName: client.ObjectKey{
Name: tt.namespace.Name,
},
}
_, err := reconciler.Reconcile(ctx, req)
require.NoError(t, err)
// Verify mirrors were deleted as expected
for _, mirrorName := range tt.expectedDeleted {
mirror := &unstructured.Unstructured{}
mirror.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "Secret"})
err := fakeClient.Get(ctx, client.ObjectKey{
Namespace: tt.namespace.Name,
Name: mirrorName,
}, mirror)
assert.True(t, errors.IsNotFound(err),
"mirror %s should be deleted in namespace %s", mirrorName, tt.namespace.Name)
}
// Verify mirrors remain as expected
for _, mirrorName := range tt.expectedRemaining {
mirror := &unstructured.Unstructured{}
mirror.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "Secret"})
err := fakeClient.Get(ctx, client.ObjectKey{
Namespace: tt.namespace.Name,
Name: mirrorName,
}, mirror)
assert.NoError(t, err,
"mirror %s should exist in namespace %s", mirrorName, tt.namespace.Name)
}
})
}
}
// Helper functions
func makeUnstructuredSecret(name, namespace string, labels, annotations map[string]string) *unstructured.Unstructured {
secret := &unstructured.Unstructured{}
secret.SetGroupVersionKind(schema.GroupVersionKind{
Version: "v1",
Kind: "Secret",
})
secret.SetName(name)
secret.SetNamespace(namespace)
secret.SetLabels(labels)
secret.SetAnnotations(annotations)
// Set some data
_ = unstructured.SetNestedMap(secret.Object, map[string]interface{}{
"key": "dmFsdWU=", // base64("value")
}, "data")
return secret
}
func makeUnstructuredMirror(name, namespace, sourceNs, sourceName string) *unstructured.Unstructured {
mirror := &unstructured.Unstructured{}
mirror.SetGroupVersionKind(schema.GroupVersionKind{
Version: "v1",
Kind: "Secret",
})
mirror.SetName(name)
mirror.SetNamespace(namespace)
mirror.SetLabels(map[string]string{
constants.LabelManagedBy: "kubemirror",
constants.LabelMirror: "true",
})
mirror.SetAnnotations(map[string]string{
constants.AnnotationSourceNamespace: sourceNs,
constants.AnnotationSourceName: sourceName,
constants.AnnotationSourceUID: "test-uid",
})
// Set some data
_ = unstructured.SetNestedMap(mirror.Object, map[string]interface{}{
"key": "dmFsdWU=",
}, "data")
return mirror
}
// Mock namespace lister for testing
type mockNamespaceLister struct {
namespaces []string
allowMirrors map[string]bool
optOut map[string]bool
}
func (m *mockNamespaceLister) ListNamespaces(ctx context.Context) ([]string, error) {
return m.namespaces, nil
}
func (m *mockNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error) {
var result []string
for ns, allowed := range m.allowMirrors {
if allowed {
result = append(result, ns)
}
}
return result, nil
}
func (m *mockNamespaceLister) ListOptOutNamespaces(ctx context.Context) ([]string, error) {
var result []string
for ns, optedOut := range m.optOut {
if optedOut {
result = append(result, ns)
}
}
return result, nil
}
+168 -65
View File
@@ -15,7 +15,6 @@ import (
ctrl "sigs.k8s.io/controller-runtime" ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log"
@@ -36,6 +35,7 @@ type SourceReconciler struct {
Filter *filter.NamespaceFilter Filter *filter.NamespaceFilter
NamespaceLister NamespaceLister NamespaceLister NamespaceLister
GVK schema.GroupVersionKind // The resource type this reconciler handles GVK schema.GroupVersionKind // The resource type this reconciler handles
APIReader client.Reader // Direct API reader (bypasses cache)
} }
// NamespaceLister provides a list of all namespaces in the cluster. // NamespaceLister provides a list of all namespaces in the cluster.
@@ -43,20 +43,83 @@ type SourceReconciler struct {
type NamespaceLister interface { type NamespaceLister interface {
ListNamespaces(ctx context.Context) ([]string, error) ListNamespaces(ctx context.Context) ([]string, error)
ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error) ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error)
ListOptOutNamespaces(ctx context.Context) ([]string, error)
} }
// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch // +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch
// getSourceWithFreshness fetches a source resource with optional freshness verification.
// This implements a hybrid caching strategy:
// 1. First read from informer cache (fast, local)
// 2. If VerifySourceFreshness is enabled, make direct API call via APIReader
// 3. If resourceVersions differ, cache is stale - return fresh version from API
// 4. If resourceVersions match, cache is current - return cached version
//
// This prevents the race condition where:
// - Watch event arrives: "Secret changed!"
// - Reconciliation starts immediately
// - Cache hasn't updated yet (5-20 second lag)
// - We read stale data and mirror it
//
// Trade-off: 2x API calls when cache is stale, but guarantees data freshness.
func (r *SourceReconciler) getSourceWithFreshness(ctx context.Context, key client.ObjectKey, gvk schema.GroupVersionKind) (*unstructured.Unstructured, error) {
logger := log.FromContext(ctx)
// First try: Read from cache (fast)
cached := &unstructured.Unstructured{}
cached.SetGroupVersionKind(gvk)
if err := r.Get(ctx, key, cached); err != nil {
return nil, err
}
// If freshness verification is disabled, return cached version immediately
if !r.Config.VerifySourceFreshness {
logger.V(2).Info("using cached source (freshness check disabled)", "resourceVersion", cached.GetResourceVersion())
return cached, nil
}
// If APIReader is not available (e.g., in tests), fall back to cached version
if r.APIReader == nil {
logger.V(2).Info("using cached source (no APIReader available)", "resourceVersion", cached.GetResourceVersion())
return cached, nil
}
cachedRV := cached.GetResourceVersion()
// Second try: Direct API read to verify freshness (bypasses cache)
fresh := &unstructured.Unstructured{}
fresh.SetGroupVersionKind(gvk)
if err := r.APIReader.Get(ctx, key, fresh); err != nil {
// If direct API read fails, fall back to cached version
logger.V(1).Info("direct API read failed, using cached version", "error", err, "cachedRV", cachedRV)
return cached, nil
}
freshRV := fresh.GetResourceVersion()
// Compare resource versions
if cachedRV != freshRV {
// Cache is stale - return fresh version from API
logger.V(1).Info("cache stale, using fresh API version",
"cachedRV", cachedRV,
"freshRV", freshRV)
return fresh, nil
}
// Cache is current - return cached version (saves memory allocation)
logger.V(2).Info("cache current", "resourceVersion", cachedRV)
return cached, nil
}
// Reconcile processes a single source resource. // Reconcile processes a single source resource.
func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx).WithValues("namespace", req.Namespace, "name", req.Name) logger := log.FromContext(ctx).WithValues("namespace", req.Namespace, "name", req.Name)
// Fetch the source resource as unstructured (works for all resource types) // Fetch the source resource with optional freshness verification
source := &unstructured.Unstructured{} source, err := r.getSourceWithFreshness(ctx, req.NamespacedName, r.GVK)
source.SetGroupVersionKind(r.GVK) // Set the GVK so the client knows what to fetch if err != nil {
if err := r.Get(ctx, req.NamespacedName, source); err != nil {
if errors.IsNotFound(err) { if errors.IsNotFound(err) {
// Resource deleted - nothing to do // Resource deleted - nothing to do
return ctrl.Result{}, nil return ctrl.Result{}, nil
@@ -74,25 +137,50 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
} }
// Check if resource is enabled for mirroring // Check if resource is enabled for mirroring
if !isEnabledForMirroring(sourceObj) { // Check if resource is being deleted
// Silently skip - don't log as it would be too noisy if !sourceObj.GetDeletionTimestamp().IsZero() {
return r.handleDisabled(ctx, sourceObj) // Resource is being deleted - clean up mirrors and remove finalizer
if containsString(sourceObj.GetFinalizers(), constants.FinalizerName) {
logger.Info("source being deleted, cleaning up all mirrors")
if err := r.deleteAllMirrors(ctx, sourceObj); err != nil {
logger.Error(err, "failed to delete all mirrors during source deletion")
return ctrl.Result{}, err
}
// Remove finalizer to allow resource deletion
logger.Info("removing finalizer from source resource")
finalizers := removeString(sourceObj.GetFinalizers(), constants.FinalizerName)
sourceObj.SetFinalizers(finalizers)
if err := r.Update(ctx, source); err != nil {
logger.Error(err, "failed to remove finalizer")
return ctrl.Result{}, err
}
logger.Info("finalizer removed, resource can now be deleted")
}
return ctrl.Result{}, nil
} }
// Handle deletion if !isEnabledForMirroring(sourceObj) {
if !sourceObj.GetDeletionTimestamp().IsZero() { // Resource is disabled - remove finalizer if present and delete all mirrors
return r.handleDeletion(ctx, source, sourceObj) if containsString(sourceObj.GetFinalizers(), constants.FinalizerName) {
return r.handleDisabled(ctx, sourceObj)
}
// No finalizer, just skip
return ctrl.Result{}, nil
} }
// Add finalizer if not present // Add finalizer if not present
// source (*unstructured.Unstructured) already implements client.Object if !containsString(sourceObj.GetFinalizers(), constants.FinalizerName) {
if !controllerutil.ContainsFinalizer(source, constants.FinalizerName) { logger.Info("adding finalizer to source resource")
controllerutil.AddFinalizer(source, constants.FinalizerName) finalizers := append(sourceObj.GetFinalizers(), constants.FinalizerName)
sourceObj.SetFinalizers(finalizers)
if err := r.Update(ctx, source); err != nil { if err := r.Update(ctx, source); err != nil {
logger.Error(err, "failed to add finalizer") logger.Error(err, "failed to add finalizer")
return ctrl.Result{}, err return ctrl.Result{}, err
} }
logger.V(1).Info("added finalizer") logger.Info("finalizer added")
// Requeue to continue with reconciliation after finalizer is added
return ctrl.Result{Requeue: true}, nil
} }
// Get target namespaces // Get target namespaces
@@ -148,57 +236,32 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
return ctrl.Result{}, nil return ctrl.Result{}, nil
} }
// handleDeletion removes finalizer after cleaning up all mirrors.
func (r *SourceReconciler) handleDeletion(ctx context.Context, source runtime.Object, sourceObj metav1.Object) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// source (*unstructured.Unstructured) already implements client.Object
sourceUnstructured := source.(*unstructured.Unstructured)
if !controllerutil.ContainsFinalizer(sourceUnstructured, constants.FinalizerName) {
return ctrl.Result{}, nil
}
// Delete all mirrors
if err := r.deleteAllMirrors(ctx, sourceObj); err != nil {
logger.Error(err, "failed to delete mirrors")
return ctrl.Result{}, err
}
// Remove finalizer
controllerutil.RemoveFinalizer(sourceUnstructured, constants.FinalizerName)
if err := r.Update(ctx, sourceUnstructured); err != nil {
logger.Error(err, "failed to remove finalizer")
return ctrl.Result{}, err
}
logger.Info("finalizer removed, mirrors deleted")
return ctrl.Result{}, nil
}
// handleDisabled removes mirrors when a resource is disabled. // handleDisabled removes mirrors when a resource is disabled.
func (r *SourceReconciler) handleDisabled(ctx context.Context, sourceObj metav1.Object) (ctrl.Result, error) { func (r *SourceReconciler) handleDisabled(ctx context.Context, sourceObj metav1.Object) (ctrl.Result, error) {
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
// Source is already a client.Object (unstructured implements it) // Delete all mirrors for this disabled source
sourceClient := sourceObj.(client.Object) if err := r.deleteAllMirrors(ctx, sourceObj); err != nil {
logger.Error(err, "failed to delete mirrors for disabled resource")
return ctrl.Result{}, err
}
// If resource has finalizer, clean up mirrors and remove it // Remove finalizer if present
if controllerutil.ContainsFinalizer(sourceClient, constants.FinalizerName) { if containsString(sourceObj.GetFinalizers(), constants.FinalizerName) {
if err := r.deleteAllMirrors(ctx, sourceObj); err != nil { logger.Info("removing finalizer from disabled resource")
logger.Error(err, "failed to delete mirrors for disabled resource") finalizers := removeString(sourceObj.GetFinalizers(), constants.FinalizerName)
return ctrl.Result{}, err sourceObj.SetFinalizers(finalizers)
}
// Remove finalizer // Get the unstructured object to update - sourceObj is already *unstructured.Unstructured
controllerutil.RemoveFinalizer(sourceClient, constants.FinalizerName) source := sourceObj.(*unstructured.Unstructured)
if err := r.Update(ctx, sourceClient); err != nil { if err := r.Update(ctx, source); err != nil {
logger.Error(err, "failed to remove finalizer from disabled resource") logger.Error(err, "failed to remove finalizer from disabled resource")
return ctrl.Result{}, err return ctrl.Result{}, err
} }
logger.V(1).Info("finalizer removed from disabled resource")
logger.Info("mirrors deleted and finalizer removed for disabled resource")
} }
logger.V(1).Info("mirrors deleted for disabled resource")
return ctrl.Result{}, nil return ctrl.Result{}, nil
} }
@@ -216,10 +279,24 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
return fmt.Errorf("failed to get existing mirror: %w", err) return fmt.Errorf("failed to get existing mirror: %w", err)
} }
// If freshness verification is enabled and mirror exists, verify it's fresh too
if err == nil && r.Config.VerifySourceFreshness && r.APIReader != nil {
fresh := &unstructured.Unstructured{}
fresh.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
if apiErr := r.APIReader.Get(ctx, client.ObjectKey{Namespace: targetNs, Name: sourceObj.GetName()}, fresh); apiErr == nil {
if fresh.GetResourceVersion() != existing.GetResourceVersion() {
logger.V(2).Info("mirror cache stale, using fresh API version",
"cachedRV", existing.GetResourceVersion(),
"freshRV", fresh.GetResourceVersion())
existing = fresh
}
}
}
if err == nil { if err == nil {
// Mirror exists - check if it's managed by us // Mirror exists - check if it's managed by us
if !IsManagedByUs(existing) { if !IsManagedByUs(existing) {
logger.Info("target resource exists but not managed by kubemirror, skipping") logger.V(1).Info("target resource exists but not managed by kubemirror, skipping")
return nil return nil
} }
@@ -230,7 +307,7 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
} }
if !needsSync { if !needsSync {
logger.V(1).Info("mirror is up to date") logger.V(2).Info("mirror is up to date")
return nil return nil
} }
@@ -243,7 +320,7 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
return fmt.Errorf("failed to update mirror in cluster: %w", err) return fmt.Errorf("failed to update mirror in cluster: %w", err)
} }
logger.Info("mirror updated") logger.V(1).Info("mirror updated")
return nil return nil
} }
@@ -257,7 +334,7 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
return fmt.Errorf("failed to create mirror in cluster: %w", err) return fmt.Errorf("failed to create mirror in cluster: %w", err)
} }
logger.Info("mirror created") logger.V(1).Info("mirror created")
return nil return nil
} }
@@ -407,11 +484,18 @@ func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceOb
return nil, fmt.Errorf("failed to list allow-mirrors namespaces: %w", err) return nil, fmt.Errorf("failed to list allow-mirrors namespaces: %w", err)
} }
// Get namespaces that have explicitly opted out (allow-mirrors="false")
optOutNamespaces, err := r.NamespaceLister.ListOptOutNamespaces(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list opt-out namespaces: %w", err)
}
// Resolve target namespaces // Resolve target namespaces
targetNamespaces := filter.ResolveTargetNamespaces( targetNamespaces := filter.ResolveTargetNamespaces(
patterns, patterns,
allNamespaces, allNamespaces,
allowMirrorsNamespaces, allowMirrorsNamespaces,
optOutNamespaces,
sourceObj.GetNamespace(), sourceObj.GetNamespace(),
r.Filter, r.Filter,
) )
@@ -474,12 +558,10 @@ func (r *SourceReconciler) SetupWithManagerForResourceType(
obj := &unstructured.Unstructured{} obj := &unstructured.Unstructured{}
obj.SetGroupVersionKind(gvk) obj.SetGroupVersionKind(gvk)
// Create unique controller name including version to avoid collisions // Create unique controller name including version and group to avoid collisions
// e.g., "HorizontalPodAutoscaler.v1.autoscaling" // e.g., "HorizontalPodAutoscaler.v1.autoscaling" or "Secret.v1." (empty group for core resources)
controllerName := gvk.Kind + "." + gvk.Version // This matches the naming convention used by mirror reconcilers
if gvk.Group != "" { controllerName := gvk.Kind + "." + gvk.Version + "." + gvk.Group
controllerName += "." + gvk.Group
}
// Create mirror object for watching // Create mirror object for watching
mirrorObj := &unstructured.Unstructured{} mirrorObj := &unstructured.Unstructured{}
@@ -528,3 +610,24 @@ func (r *SourceReconciler) mapMirrorToSource(ctx context.Context, obj client.Obj
}, },
} }
} }
// containsString checks if a slice contains a string.
func containsString(slice []string, s string) bool {
for _, item := range slice {
if item == s {
return true
}
}
return false
}
// removeString removes a string from a slice.
func removeString(slice []string, s string) []string {
result := make([]string, 0, len(slice))
for _, item := range slice {
if item != s {
result = append(result, item)
}
}
return result
}
+15
View File
@@ -129,6 +129,11 @@ func (m *MockNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([
return args.Get(0).([]string), args.Error(1) return args.Get(0).([]string), args.Error(1)
} }
func (m *MockNamespaceLister) ListOptOutNamespaces(ctx context.Context) ([]string, error) {
args := m.Called(ctx)
return args.Get(0).([]string), args.Error(1)
}
func TestIsEnabledForMirroring(t *testing.T) { func TestIsEnabledForMirroring(t *testing.T) {
tests := []struct { tests := []struct {
obj metav1.Object obj metav1.Object
@@ -277,6 +282,7 @@ func TestSourceReconciler_resolveTargetNamespaces(t *testing.T) {
if tt.expectListCalls { if tt.expectListCalls {
mockLister.On("ListNamespaces", mock.Anything).Return(tt.allNamespaces, nil) mockLister.On("ListNamespaces", mock.Anything).Return(tt.allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(tt.allowMirrorsNamespaces, nil) mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(tt.allowMirrorsNamespaces, nil)
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
} }
r := &SourceReconciler{ r := &SourceReconciler{
@@ -441,6 +447,7 @@ func BenchmarkResolveTargetNamespaces(b *testing.B) {
} }
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil) mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(allNamespaces[:50], nil) mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(allNamespaces[:50], nil)
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
r := &SourceReconciler{ r := &SourceReconciler{
Config: &config.Config{}, Config: &config.Config{},
@@ -573,6 +580,9 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
constants.AnnotationSync: "true", constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "all-labeled", // Changed from "all" constants.AnnotationTargetNamespaces: "all-labeled", // Changed from "all"
}, },
"finalizers": []interface{}{
constants.FinalizerName,
},
}, },
"data": map[string]interface{}{ "data": map[string]interface{}{
"password": "c2VjcmV0", "password": "c2VjcmV0",
@@ -616,6 +626,7 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil) mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(allowMirrorsNamespaces, nil) mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(allowMirrorsNamespaces, nil)
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
// Mock Get for source // Mock Get for source
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "test-secret"}, mock.Anything). mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "test-secret"}, mock.Anything).
@@ -699,6 +710,9 @@ func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T)
constants.AnnotationSync: "true", constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "prod-*", // Changed from "app-*" constants.AnnotationTargetNamespaces: "prod-*", // Changed from "app-*"
}, },
"finalizers": []interface{}{
constants.FinalizerName,
},
}, },
"data": map[string]interface{}{ "data": map[string]interface{}{
"key": "value", "key": "value",
@@ -739,6 +753,7 @@ func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T)
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil) mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return([]string{}, nil) mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return([]string{}, nil)
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
// Mock Get for source // Mock Get for source
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "app-config"}, mock.Anything). mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "app-config"}, mock.Anything).
+14 -3
View File
@@ -105,6 +105,7 @@ func ParseTargetNamespaces(value string) []string {
// - patterns: namespace patterns from annotation // - patterns: namespace patterns from annotation
// - allNamespaces: list of all namespaces in cluster // - allNamespaces: list of all namespaces in cluster
// - allowMirrorsNamespaces: namespaces with allow-mirrors label // - allowMirrorsNamespaces: namespaces with allow-mirrors label
// - optOutNamespaces: namespaces with allow-mirrors="false" (explicitly opted out)
// - sourceNamespace: exclude this namespace to prevent self-copy // - sourceNamespace: exclude this namespace to prevent self-copy
// - filter: namespace filter for exclusions // - filter: namespace filter for exclusions
// //
@@ -113,6 +114,7 @@ func ResolveTargetNamespaces(
patterns []string, patterns []string,
allNamespaces []string, allNamespaces []string,
allowMirrorsNamespaces []string, allowMirrorsNamespaces []string,
optOutNamespaces []string,
sourceNamespace string, sourceNamespace string,
filter *NamespaceFilter, filter *NamespaceFilter,
) []string { ) []string {
@@ -120,21 +122,30 @@ func ResolveTargetNamespaces(
return nil return nil
} }
// Create map of opt-out namespaces for fast lookup
optOutMap := make(map[string]bool)
for _, ns := range optOutNamespaces {
optOutMap[ns] = true
}
// Use map to deduplicate // Use map to deduplicate
targetMap := make(map[string]bool) targetMap := make(map[string]bool)
for _, pattern := range patterns { for _, pattern := range patterns {
switch pattern { switch pattern {
case constants.TargetNamespacesAll: case constants.TargetNamespacesAll:
// Mirror to all namespaces (except source and excluded) // Mirror to all namespaces (except source, excluded, and opt-out)
// This implements opt-OUT model: namespaces without labels get mirrors
// Only namespaces with allow-mirrors="false" are excluded
for _, ns := range allNamespaces { for _, ns := range allNamespaces {
if ns != sourceNamespace && filter.IsAllowed(ns) { if ns != sourceNamespace && filter.IsAllowed(ns) && !optOutMap[ns] {
targetMap[ns] = true targetMap[ns] = true
} }
} }
case constants.TargetNamespacesAllLabeled: case constants.TargetNamespacesAllLabeled:
// Mirror only to namespaces with allow-mirrors label // Mirror only to namespaces with allow-mirrors="true" label
// This implements opt-IN model
for _, ns := range allowMirrorsNamespaces { for _, ns := range allowMirrorsNamespaces {
if ns != sourceNamespace && filter.IsAllowed(ns) { if ns != sourceNamespace && filter.IsAllowed(ns) {
targetMap[ns] = true targetMap[ns] = true
+7
View File
@@ -318,6 +318,7 @@ func TestResolveTargetNamespaces(t *testing.T) {
tt.patterns, tt.patterns,
tt.allNamespaces, tt.allNamespaces,
tt.allowMirrorsNamespaces, tt.allowMirrorsNamespaces,
[]string{}, // optOutNamespaces - empty for these tests
tt.sourceNamespace, tt.sourceNamespace,
tt.filter, tt.filter,
) )
@@ -342,6 +343,7 @@ func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
[]string{"all"}, []string{"all"},
[]string{}, []string{},
[]string{}, []string{},
[]string{}, // optOutNamespaces
"default", "default",
NewNamespaceFilter([]string{}, []string{}), NewNamespaceFilter([]string{}, []string{}),
) )
@@ -354,6 +356,7 @@ func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
[]string{"[invalid"}, []string{"[invalid"},
[]string{"app1"}, []string{"app1"},
[]string{}, []string{},
[]string{}, // optOutNamespaces
"default", "default",
NewNamespaceFilter([]string{}, []string{}), NewNamespaceFilter([]string{}, []string{}),
) )
@@ -366,6 +369,7 @@ func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
[]string{"all"}, []string{"all"},
[]string{"app1", "app2", "app3"}, []string{"app1", "app2", "app3"},
[]string{}, []string{},
[]string{}, // optOutNamespaces
"default", "default",
strictFilter, strictFilter,
) )
@@ -541,6 +545,7 @@ func BenchmarkResolveTargetNamespaces(b *testing.B) {
tt.patterns, tt.patterns,
allNamespaces, allNamespaces,
allowMirrorsNamespaces, allowMirrorsNamespaces,
[]string{}, // optOutNamespaces
"default", "default",
filter, filter,
) )
@@ -566,6 +571,7 @@ func BenchmarkResolveTargetNamespaces_LargeScale(b *testing.B) {
[]string{constants.TargetNamespacesAll}, []string{constants.TargetNamespacesAll},
allNamespaces, allNamespaces,
allowMirrorsNamespaces, allowMirrorsNamespaces,
[]string{}, // optOutNamespaces
"default", "default",
filter, filter,
) )
@@ -579,6 +585,7 @@ func BenchmarkResolveTargetNamespaces_LargeScale(b *testing.B) {
[]string{"namespace-*"}, []string{"namespace-*"},
allNamespaces, allNamespaces,
allowMirrorsNamespaces, allowMirrorsNamespaces,
[]string{}, // optOutNamespaces
"default", "default",
filter, filter,
) )
+32 -4
View File
@@ -10,6 +10,8 @@ import (
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
) )
// ComputeContentHash computes a SHA256 hash of the resource's actual content. // ComputeContentHash computes a SHA256 hash of the resource's actual content.
@@ -49,19 +51,37 @@ func extractContent(obj runtime.Object) (interface{}, error) {
// extractSecretContent extracts content from a Secret. // extractSecretContent extracts content from a Secret.
func extractSecretContent(secret *corev1.Secret) map[string]interface{} { func extractSecretContent(secret *corev1.Secret) map[string]interface{} {
return map[string]interface{}{ content := map[string]interface{}{
"type": string(secret.Type), "type": string(secret.Type),
"data": secret.Data, "data": secret.Data,
"stringData": secret.StringData, "stringData": secret.StringData,
} }
// Include transform annotation in hash so changes to transformation rules trigger updates
if secret.Annotations != nil {
if transform, exists := secret.Annotations[constants.AnnotationTransform]; exists {
content["transform"] = transform
}
}
return content
} }
// extractConfigMapContent extracts content from a ConfigMap. // extractConfigMapContent extracts content from a ConfigMap.
func extractConfigMapContent(cm *corev1.ConfigMap) map[string]interface{} { func extractConfigMapContent(cm *corev1.ConfigMap) map[string]interface{} {
return map[string]interface{}{ content := map[string]interface{}{
"data": cm.Data, "data": cm.Data,
"binaryData": cm.BinaryData, "binaryData": cm.BinaryData,
} }
// Include transform annotation in hash so changes to transformation rules trigger updates
if cm.Annotations != nil {
if transform, exists := cm.Annotations[constants.AnnotationTransform]; exists {
content["transform"] = transform
}
}
return content
} }
// extractUnstructuredContent extracts content from an unstructured resource (CRDs, etc.). // extractUnstructuredContent extracts content from an unstructured resource (CRDs, etc.).
@@ -98,6 +118,14 @@ func extractUnstructuredContent(obj runtime.Object) (interface{}, error) {
} }
} }
// Include transform annotation in hash so changes to transformation rules trigger updates
annotations := uCopy.GetAnnotations()
if annotations != nil {
if transform, exists := annotations[constants.AnnotationTransform]; exists {
content["transform"] = transform
}
}
return content, nil return content, nil
} }
@@ -109,7 +137,7 @@ func NeedsSync(source, target runtime.Object, targetAnnotations map[string]strin
// Layer 1: Generation-based check (for resources that support it) // Layer 1: Generation-based check (for resources that support it)
sourceGen := getGeneration(source) sourceGen := getGeneration(source)
if sourceGen > 0 { if sourceGen > 0 {
targetSourceGen := targetAnnotations["source-generation"] targetSourceGen := targetAnnotations[constants.AnnotationSourceGeneration]
if fmt.Sprintf("%d", sourceGen) != targetSourceGen { if fmt.Sprintf("%d", sourceGen) != targetSourceGen {
return true, nil // Generation changed return true, nil // Generation changed
} }
@@ -121,7 +149,7 @@ func NeedsSync(source, target runtime.Object, targetAnnotations map[string]strin
return false, fmt.Errorf("failed to compute source hash: %w", err) return false, fmt.Errorf("failed to compute source hash: %w", err)
} }
targetSourceHash := targetAnnotations["source-content-hash"] targetSourceHash := targetAnnotations[constants.AnnotationSourceContentHash]
if sourceHash != targetSourceHash { if sourceHash != targetSourceHash {
return true, nil // Content changed return true, nil // Content changed
} }
+6 -5
View File
@@ -3,6 +3,7 @@ package hash
import ( import (
"testing" "testing"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@@ -374,8 +375,8 @@ func TestNeedsSync(t *testing.T) {
}, },
target: &unstructured.Unstructured{}, target: &unstructured.Unstructured{},
targetAnnotations: map[string]string{ targetAnnotations: map[string]string{
"source-generation": "3", constants.AnnotationSourceGeneration: "3",
"source-content-hash": "abc123", constants.AnnotationSourceContentHash: "abc123",
}, },
want: true, want: true,
wantError: false, wantError: false,
@@ -387,8 +388,8 @@ func TestNeedsSync(t *testing.T) {
}, },
target: &corev1.Secret{}, target: &corev1.Secret{},
targetAnnotations: map[string]string{ targetAnnotations: map[string]string{
"source-generation": "0", constants.AnnotationSourceGeneration: "0",
"source-content-hash": mustComputeHash(t, &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}), constants.AnnotationSourceContentHash: mustComputeHash(t, &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}),
}, },
want: false, want: false,
wantError: false, wantError: false,
@@ -400,7 +401,7 @@ func TestNeedsSync(t *testing.T) {
}, },
target: &corev1.ConfigMap{}, target: &corev1.ConfigMap{},
targetAnnotations: map[string]string{ targetAnnotations: map[string]string{
"source-content-hash": "oldhash", constants.AnnotationSourceContentHash: "oldhash",
}, },
want: true, want: true,
wantError: false, wantError: false,
+42 -10
View File
@@ -3,6 +3,7 @@ package transformer
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"fmt" "fmt"
"strings" "strings"
"text/template" "text/template"
@@ -10,14 +11,8 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
)
const ( "github.com/lukaszraczylo/kubemirror/pkg/constants"
// 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. // Transformer applies transformation rules to Kubernetes resources.
@@ -92,7 +87,7 @@ func (t *Transformer) parseTransformRules(u *unstructured.Unstructured) (*Transf
return &TransformRules{}, nil return &TransformRules{}, nil
} }
rulesYAML, exists := annotations[AnnotationTransform] rulesYAML, exists := annotations[constants.AnnotationTransform]
if !exists || rulesYAML == "" { if !exists || rulesYAML == "" {
return &TransformRules{}, nil return &TransformRules{}, nil
} }
@@ -255,7 +250,7 @@ func (t *Transformer) isStrictMode(u *unstructured.Unstructured) bool {
return false return false
} }
strictValue, exists := annotations[AnnotationTransformStrict] strictValue, exists := annotations[constants.AnnotationTransformStrict]
return exists && (strictValue == "true" || strictValue == "1") return exists && (strictValue == "true" || strictValue == "1")
} }
@@ -409,7 +404,20 @@ func setNestedField(obj map[string]interface{}, path []string, value interface{}
return fmt.Errorf("cannot set key %s on non-map %T", finalSegment, current) return fmt.Errorf("cannot set key %s on non-map %T", finalSegment, current)
} }
currentMap[finalSegment] = value // Special handling for Secret data fields
// Secrets require base64-encoded values in .data field
finalValue := value
if isSecretDataField(obj, path) {
// Convert value to string and base64-encode it
strValue, ok := value.(string)
if !ok {
// Try to convert to string
strValue = fmt.Sprintf("%v", value)
}
finalValue = base64Encode(strValue)
}
currentMap[finalSegment] = finalValue
return nil return nil
} }
@@ -512,3 +520,27 @@ func templateFuncs() template.FuncMap {
}, },
} }
} }
// isSecretDataField checks if the path points to a Secret's .data field.
func isSecretDataField(obj map[string]interface{}, path []string) bool {
// Check if this is a Secret by looking at apiVersion and kind
kind, hasKind := obj["kind"]
apiVersion, hasAPI := obj["apiVersion"]
if !hasKind || !hasAPI {
return false
}
// Check if it's a Secret (kind=Secret, apiVersion=v1)
if kind != "Secret" || apiVersion != "v1" {
return false
}
// Check if path starts with "data"
return len(path) >= 1 && path[0] == "data"
}
// base64Encode encodes a string to base64.
func base64Encode(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}
+18 -17
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"testing" "testing"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@@ -30,7 +31,7 @@ func TestTransformer_Transform(t *testing.T) {
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: ` constants.AnnotationTransform: `
rules: rules:
- path: data.LOG_LEVEL - path: data.LOG_LEVEL
value: "error" value: "error"
@@ -63,7 +64,7 @@ rules:
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: ` constants.AnnotationTransform: `
rules: rules:
- path: data.API_URL - path: data.API_URL
template: "https://{{.TargetNamespace}}.api.example.com" template: "https://{{.TargetNamespace}}.api.example.com"
@@ -93,7 +94,7 @@ rules:
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: ` constants.AnnotationTransform: `
rules: rules:
- path: data.NAMESPACE_UPPER - path: data.NAMESPACE_UPPER
template: "{{upper .TargetNamespace}}" template: "{{upper .TargetNamespace}}"
@@ -136,7 +137,7 @@ rules:
"app": "myapp", "app": "myapp",
}, },
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: ` constants.AnnotationTransform: `
rules: rules:
- path: metadata.labels - path: metadata.labels
merge: merge:
@@ -165,7 +166,7 @@ rules:
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: ` constants.AnnotationTransform: `
rules: rules:
- path: metadata.labels - path: metadata.labels
merge: merge:
@@ -193,7 +194,7 @@ rules:
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: ` constants.AnnotationTransform: `
rules: rules:
- path: data.DEBUG_MODE - path: data.DEBUG_MODE
delete: true delete: true
@@ -227,8 +228,8 @@ rules:
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: "invalid: yaml: [[[", constants.AnnotationTransform: "invalid: yaml: [[[",
AnnotationTransformStrict: "true", constants.AnnotationTransformStrict: "true",
}, },
}, },
Data: map[string]string{}, Data: map[string]string{},
@@ -247,11 +248,11 @@ rules:
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: ` constants.AnnotationTransform: `
rules: rules:
- value: "something" - value: "something"
`, `,
AnnotationTransformStrict: "true", constants.AnnotationTransformStrict: "true",
}, },
}, },
Data: map[string]string{}, Data: map[string]string{},
@@ -273,8 +274,8 @@ rules:
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: rules, constants.AnnotationTransform: rules,
AnnotationTransformStrict: "true", constants.AnnotationTransformStrict: "true",
}, },
}, },
Data: map[string]string{}, Data: map[string]string{},
@@ -319,7 +320,7 @@ rules:
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: "invalid yaml [[[", constants.AnnotationTransform: "invalid yaml [[[",
}, },
}, },
Data: map[string]string{ Data: map[string]string{
@@ -347,7 +348,7 @@ rules:
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: ` constants.AnnotationTransform: `
rules: rules:
- path: data.KEY1 - path: data.KEY1
value: "first" value: "first"
@@ -402,7 +403,7 @@ rules:
"name": "test-pod", "name": "test-pod",
"namespace": "default", "namespace": "default",
"annotations": map[string]interface{}{ "annotations": map[string]interface{}{
AnnotationTransform: ` constants.AnnotationTransform: `
rules: rules:
- path: spec.containers[0].image - path: spec.containers[0].image
template: "registry.{{.TargetNamespace}}.example.com/app:v1" template: "registry.{{.TargetNamespace}}.example.com/app:v1"
@@ -457,7 +458,7 @@ rules:
Name: "test-config", Name: "test-config",
Namespace: "default", Namespace: "default",
Annotations: map[string]string{ Annotations: map[string]string{
AnnotationTransform: ` constants.AnnotationTransform: `
rules: rules:
- path: data.VALUE - path: data.VALUE
template: "{{.TargetNamespace}}-empty" template: "{{.TargetNamespace}}-empty"
@@ -1336,7 +1337,7 @@ rules:
"name": "test-config", "name": "test-config",
"namespace": "source-namespace", "namespace": "source-namespace",
"annotations": map[string]interface{}{ "annotations": map[string]interface{}{
AnnotationTransform: tt.rules, constants.AnnotationTransform: tt.rules,
}, },
}, },
}, },