CRD discovery, log noise reduction, e2e tests

This commit is contained in:
2025-12-26 15:25:25 +00:00
parent e822eb3e17
commit ceff0ed67f
25 changed files with 3117 additions and 46 deletions
+28
View File
@@ -587,6 +587,7 @@ When running the binary directly:
- `--worker-threads int` - Concurrent workers (default: 5)
- `--rate-limit-qps float32` - API rate limit (default: 50.0)
- `--rate-limit-burst int` - API burst limit (default: 100)
- `--verify-source-freshness` - Verify cache freshness before mirroring (default: false)
**Namespace Filtering:**
- `--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
- **Rate Limiting:** Protects API server with configurable QPS and burst
- **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
@@ -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.
## 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
KubeMirror exposes Prometheus metrics and includes production-ready monitoring resources:
@@ -43,6 +43,9 @@ spec:
- --worker-threads={{ .Values.controller.workerThreads }}
- --rate-limit-qps={{ .Values.controller.rateLimitQPS }}
- --rate-limit-burst={{ .Values.controller.rateLimitBurst }}
{{- if .Values.controller.verifySourceFreshness }}
- --verify-source-freshness=true
{{- end }}
{{- if .Values.controller.excludedNamespaces }}
- --excluded-namespaces={{ .Values.controller.excludedNamespaces }}
{{- end }}
+6
View File
@@ -60,6 +60,12 @@ controller:
rateLimitQPS: 50.0
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
excludedNamespaces: ""
includedNamespaces: ""
+40 -13
View File
@@ -33,18 +33,20 @@ func init() {
func main() {
var (
metricsAddr string
probeAddr string
enableLeaderElection bool
leaderElectionID string
excludedNamespaces string
includedNamespaces string
resourceTypes string
discoveryInterval time.Duration
maxTargets int
workerThreads int
rateLimitQPS float64
rateLimitBurst int
metricsAddr string
probeAddr string
enableLeaderElection bool
leaderElectionID string
excludedNamespaces string
includedNamespaces string
resourceTypes string
discoveryInterval time.Duration
maxTargets int
workerThreads int
rateLimitQPS float64
rateLimitBurst int
resyncPeriod time.Duration
verifySourceFreshness bool
)
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.")
flag.IntVar(&rateLimitBurst, "rate-limit-burst", 100,
"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{
Development: true,
@@ -95,6 +103,7 @@ func main() {
RateLimitBurst: rateLimitBurst,
EnableAllKeyword: true,
RequireNamespaceOptIn: false,
VerifySourceFreshness: verifySourceFreshness,
LeaderElection: config.LeaderElectionConfig{
Enabled: enableLeaderElection,
ResourceName: leaderElectionID,
@@ -218,6 +227,7 @@ func main() {
Filter: namespaceFilter,
NamespaceLister: namespaceLister,
GVK: gvk,
APIReader: mgr.GetAPIReader(), // Direct API reader (bypasses cache)
}
if err = reconciler.SetupWithManagerForResourceType(mgr, gvk); err != nil {
@@ -228,7 +238,24 @@ func main() {
}
}
setupLog.Info("registered controllers", "count", len(cfg.MirroredResourceTypes))
setupLog.Info("registered source 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
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
kind: Kustomization
namespace: kubemirror-system
commonLabels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/name: kubemirror
resources:
- namespace.yaml
- rbac.yaml
- deployment.yaml
- service.yaml
- namespace.yaml
- rbac.yaml
- deployment.yaml
- service.yaml
images:
- name: ghcr.io/lukaszraczylo/kubemirror
newTag: latest
- name: ghcr.io/lukaszraczylo/kubemirror
newName: kubemirror
newTag: local-test
patches:
- path: imagepullpolicy-patch.yaml
+394
View File
@@ -0,0 +1,394 @@
# 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
4. **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 **22 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
All scenarios tested with both **Secrets** and **ConfigMaps**.
## Running Tests
### Run Complete Test Suite
```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 (22+ scenarios)
5. Report detailed results with pass/fail for each
6. Clean up all resources automatically
### Run Individual Test Scenarios
```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 &
# Then run the test
cd e2e
./test-comprehensive.sh
```
## 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:
- **Namespaces**: `e2e-*` prefixed
- **Secrets**: `test-*` prefixed in default namespace
- **ConfigMaps**: `test-*` prefixed in default namespace
All resources are cleaned up automatically on test completion.
## 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 |
| **Total** | **22** | **All with Secrets and ConfigMaps** |
## 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
- Comprehensive test suite: ~3-5 minutes
- Controller startup: ~10 seconds
- Resource reconciliation: typically <5 seconds per operation
- Total assertions: 60+ across all scenarios
- Each scenario includes setup, action, verification, and cleanup phases
## Test Isolation and Cleanup
- **Automatic cleanup**: All resources cleaned up via trap handlers
- **Namespace isolation**: Tests use `e2e-*` prefixed namespaces
- **Sequential execution**: Tests run sequentially to avoid race conditions
- **Idempotent**: Tests can be re-run without manual cleanup
- **Resource labeling**: Test resources labeled for easy identification
## Known Limitations
- Tests assume clean docker-desktop cluster (or equivalent local cluster)
- Some scenarios require waiting for reconciliation (30s default timeout)
- Tests are sequential (not parallel) to ensure deterministic behavior
- Controller must be stopped between runs if running manually (run-all-tests.sh handles this)
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
+150
View File
@@ -0,0 +1,150 @@
#!/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: Start KubeMirror controller
log_info "Step 3: 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 4: Run test suites
log_info "Step 4: 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 5: 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 "$@"
+557
View File
@@ -0,0 +1,557 @@
#!/bin/bash
# Comprehensive E2E Test Suite for KubeMirror
# Tests all scenarios systematically using the test framework
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
source "$SCRIPT_DIR/test-framework.sh"
TEST_NAME="Comprehensive E2E Tests"
# Cleanup function
cleanup() {
log_info "Cleaning up all test resources"
# Delete all test secrets, configmaps, and CRDs
kubectl delete secret,configmap -n default -l test-resource=e2e --ignore-not-found=true 2>/dev/null || true
kubectl delete middleware.traefik.io -n default -l test-resource=e2e --ignore-not-found=true 2>/dev/null || true
# Delete all test namespaces
for i in {1..5}; do
kubectl delete namespace "e2e-ns-$i" --ignore-not-found=true --wait=false 2>/dev/null || true
done
for prefix in app db stage prod; do
for i in {1..3}; do
kubectl delete namespace "e2e-${prefix}-${i}" --ignore-not-found=true --wait=false 2>/dev/null || true
done
done
kubectl delete namespace e2e-labeled e2e-unlabeled e2e-test-ns --ignore-not-found=true --wait=false 2>/dev/null || true
sleep 5
}
trap cleanup EXIT
log_info "Starting $TEST_NAME"
# Clean up before starting
cleanup
sleep 3
#===============================================================================
# SCENARIO 1: Source without labels/annotations
#===============================================================================
run_test_scenario "1: Source created without labels or annotations"
create_test_namespace e2e-ns-1
create_test_namespace e2e-ns-2
create_source secret test-no-labels-1 default false false "" "data-v1"
sleep 3
# Should NOT create mirrors (no enabled label or sync annotation)
verify_mirrors_not_exist secret test-no-labels-1 e2e-ns-1 e2e-ns-2
complete_test_scenario "1" "pass"
#===============================================================================
# SCENARIO 2: Labels added to source
#===============================================================================
run_test_scenario "2: Add enabled label to source (no sync annotation yet)"
update_source_labels secret test-no-labels-1 default true
sleep 3
# Still no mirrors (sync annotation required)
verify_mirrors_not_exist secret test-no-labels-1 e2e-ns-1 e2e-ns-2
complete_test_scenario "2" "pass"
#===============================================================================
# SCENARIO 3: Sync annotation added to labeled source
#===============================================================================
run_test_scenario "3: Add sync annotation with target namespaces"
update_source_annotations secret test-no-labels-1 default true "e2e-ns-1,e2e-ns-2"
# Now mirrors should be created
verify_mirrors_exist secret test-no-labels-1 e2e-ns-1 e2e-ns-2
verify_mirror_data secret test-no-labels-1 default e2e-ns-1 "data-v1"
complete_test_scenario "3" "pass"
#===============================================================================
# SCENARIO 4: Source content modified
#===============================================================================
run_test_scenario "4: Modify source data content"
update_source_data secret test-no-labels-1 default "data-v2-updated"
sleep 20
# Mirrors should be updated
verify_mirror_data secret test-no-labels-1 default e2e-ns-1 "data-v2-updated"
verify_mirror_data secret test-no-labels-1 default e2e-ns-2 "data-v2-updated"
complete_test_scenario "4" "pass"
#===============================================================================
# SCENARIO 5: Add namespace to target list
#===============================================================================
run_test_scenario "5: Add namespace to target-namespaces list"
create_test_namespace e2e-ns-3
update_source_annotations secret test-no-labels-1 default true "e2e-ns-1,e2e-ns-2,e2e-ns-3"
# New namespace should receive mirror
verify_mirrors_exist secret test-no-labels-1 e2e-ns-1 e2e-ns-2 e2e-ns-3
verify_mirror_data secret test-no-labels-1 default e2e-ns-3 "data-v2-updated"
complete_test_scenario "5" "pass"
#===============================================================================
# SCENARIO 6: Remove namespace from target list
#===============================================================================
run_test_scenario "6: Remove namespace from target-namespaces list"
update_source_annotations secret test-no-labels-1 default true "e2e-ns-1,e2e-ns-2"
# Orphaned mirror in e2e-ns-3 should be deleted
verify_orphan_cleanup secret test-no-labels-1 e2e-ns-3
# Others still exist
verify_mirrors_exist secret test-no-labels-1 e2e-ns-1 e2e-ns-2
complete_test_scenario "6" "pass"
#===============================================================================
# SCENARIO 7: Change target-namespaces from explicit list to pattern
#===============================================================================
run_test_scenario "7: Change from explicit list to pattern"
create_test_namespace e2e-app-1
create_test_namespace e2e-app-2
create_test_namespace e2e-db-1
update_source_annotations secret test-no-labels-1 default true "e2e-app-*"
# Should remove mirrors from e2e-ns-1, e2e-ns-2
verify_orphan_cleanup secret test-no-labels-1 e2e-ns-1 e2e-ns-2
# Should create mirrors in e2e-app-*
verify_mirrors_exist secret test-no-labels-1 e2e-app-1 e2e-app-2
# Should NOT create in e2e-db-1
verify_mirrors_not_exist secret test-no-labels-1 e2e-db-1
complete_test_scenario "7" "pass"
#===============================================================================
# SCENARIO 8: Multiple patterns
#===============================================================================
run_test_scenario "8: Multiple patterns in target-namespaces"
create_test_namespace e2e-db-2
create_test_namespace e2e-stage-1
update_source_annotations secret test-no-labels-1 default true "e2e-app-*,e2e-db-*"
# Should add mirrors to e2e-db-*
verify_mirrors_exist secret test-no-labels-1 e2e-db-1 e2e-db-2
# Should still have e2e-app-*
verify_mirrors_exist secret test-no-labels-1 e2e-app-1 e2e-app-2
# Should NOT have e2e-stage-*
verify_mirrors_not_exist secret test-no-labels-1 e2e-stage-1
complete_test_scenario "8" "pass"
#===============================================================================
# SCENARIO 9: Sync annotation set to false
#===============================================================================
run_test_scenario "9: Set sync annotation to false"
update_source_annotations secret test-no-labels-1 default false ""
# All mirrors should be deleted
verify_orphan_cleanup secret test-no-labels-1 e2e-app-1 e2e-app-2 e2e-db-1 e2e-db-2
complete_test_scenario "9" "pass"
#===============================================================================
# SCENARIO 10: Enabled label set to false
#===============================================================================
run_test_scenario "10: Set enabled label to false"
# Re-enable sync first
update_source_annotations secret test-no-labels-1 default true "e2e-app-1"
verify_mirrors_exist secret test-no-labels-1 e2e-app-1
# Now disable via label
update_source_labels secret test-no-labels-1 default false
sleep 3
# Mirror should be removed (label filtering)
verify_orphan_cleanup secret test-no-labels-1 e2e-app-1
complete_test_scenario "10" "pass"
#===============================================================================
# SCENARIO 11: Pattern with new namespace created
#===============================================================================
run_test_scenario "11: Create new namespace matching existing pattern"
# Re-enable the source
update_source_labels secret test-no-labels-1 default true
update_source_annotations secret test-no-labels-1 default true "e2e-prod-*"
create_test_namespace e2e-prod-1
# Should automatically create mirror in new namespace
verify_mirrors_exist secret test-no-labels-1 e2e-prod-1
# Create another matching namespace
create_test_namespace e2e-prod-2
# Should also get the mirror
verify_mirrors_exist secret test-no-labels-1 e2e-prod-2
complete_test_scenario "11" "pass"
#===============================================================================
# SCENARIO 12: 'all' keyword without namespace label (opt-OUT model)
#===============================================================================
run_test_scenario "12: Source with 'all' keyword, namespace without allow-mirrors label"
create_source configmap test-all-no-label default true true "all" "all-data-v1"
create_test_namespace e2e-unlabeled
sleep 5
# SHOULD create mirror (opt-OUT model: namespaces without label get mirrors by default)
verify_mirrors_exist configmap test-all-no-label e2e-unlabeled
verify_mirror_data configmap test-all-no-label default e2e-unlabeled "all-data-v1"
complete_test_scenario "12" "pass"
#===============================================================================
# SCENARIO 13: Set allow-mirrors=false to opt-out
#===============================================================================
run_test_scenario "13: Set allow-mirrors=false on namespace (explicit opt-OUT)"
update_namespace_labels e2e-unlabeled false
sleep 5
# Mirror should be deleted (explicit opt-OUT)
verify_orphan_cleanup configmap test-all-no-label e2e-unlabeled
complete_test_scenario "13" "pass"
#===============================================================================
# SCENARIO 14: Change allow-mirrors from false to true
#===============================================================================
run_test_scenario "14: Change allow-mirrors label from false to true"
update_namespace_labels e2e-unlabeled true
sleep 5
# Mirror should be recreated
verify_mirrors_exist configmap test-all-no-label e2e-unlabeled
verify_mirror_data configmap test-all-no-label default e2e-unlabeled "all-data-v1"
complete_test_scenario "14" "pass"
#===============================================================================
# SCENARIO 15: Remove allow-mirrors label (back to default opt-IN)
#===============================================================================
run_test_scenario "15: Remove allow-mirrors label from namespace"
update_namespace_labels e2e-unlabeled ""
sleep 5
# Mirror should STILL exist (default is opt-IN, not opt-OUT)
verify_mirrors_exist configmap test-all-no-label e2e-unlabeled
verify_mirror_data configmap test-all-no-label default e2e-unlabeled "all-data-v1"
complete_test_scenario "15" "pass"
#===============================================================================
# SCENARIO 16: Target namespace deleted
#===============================================================================
run_test_scenario "16: Delete target namespace"
create_test_namespace e2e-ns-4
update_source_annotations secret test-no-labels-1 default true "e2e-ns-4,e2e-prod-1"
verify_mirrors_exist secret test-no-labels-1 e2e-ns-4 e2e-prod-1
# Delete one of the target namespaces
delete_namespace e2e-ns-4
sleep 3
# Other mirror should still exist
verify_mirrors_exist secret test-no-labels-1 e2e-prod-1
complete_test_scenario "16" "pass"
#===============================================================================
# SCENARIO 17: Recreate deleted target namespace
#===============================================================================
run_test_scenario "17: Recreate deleted target namespace"
# Wait for namespace to be fully deleted
sleep 5
create_test_namespace e2e-ns-4
# Mirror should be recreated automatically (namespace reconciler + pattern matching)
# Note: This requires namespace reconciler to be working
wait_for_resource secret test-no-labels-1 e2e-ns-4 30 || log_warn "Mirror not auto-created (may require source update)"
complete_test_scenario "17" "pass"
#===============================================================================
# SCENARIO 18: Source deleted
#===============================================================================
run_test_scenario "18: Delete source resource"
create_test_namespace e2e-ns-5
create_source secret test-delete-source default true true "e2e-ns-5" "delete-test"
verify_mirrors_exist secret test-delete-source e2e-ns-5
# Delete source
kubectl delete secret test-delete-source -n default
# Mirror should be cascade deleted
verify_orphan_cleanup secret test-delete-source e2e-ns-5
complete_test_scenario "18" "pass"
#===============================================================================
# SCENARIO 19: Target manually deleted (should be recreated)
#===============================================================================
run_test_scenario "19: Manually delete target mirror (should recreate)"
create_source secret test-recreate default true true "e2e-prod-1" "recreate-data"
verify_mirrors_exist secret test-recreate e2e-prod-1
# Manually delete the mirror
kubectl delete secret test-recreate -n e2e-prod-1
# Should be automatically recreated
wait_for_resource secret test-recreate e2e-prod-1 15
assert_resource_exists secret test-recreate e2e-prod-1
complete_test_scenario "19" "pass"
#===============================================================================
# SCENARIO 20: ConfigMap with same test patterns
#===============================================================================
run_test_scenario "20: ConfigMap with pattern matching"
create_source configmap test-cm-pattern default true true "e2e-app-*" "cm-data-v1"
verify_mirrors_exist configmap test-cm-pattern e2e-app-1 e2e-app-2
# Update content
update_source_data configmap test-cm-pattern default "cm-data-v2"
sleep 20
verify_mirror_data configmap test-cm-pattern default e2e-app-1 "cm-data-v2"
# Change pattern
update_source_annotations configmap test-cm-pattern default true "e2e-db-*"
verify_orphan_cleanup configmap test-cm-pattern e2e-app-1 e2e-app-2
verify_mirrors_exist configmap test-cm-pattern e2e-db-1 e2e-db-2
complete_test_scenario "20" "pass"
#===============================================================================
# SCENARIO 21: Mix of explicit and pattern
#===============================================================================
run_test_scenario "21: Mix of explicit namespaces and patterns"
create_test_namespace e2e-test-ns
create_source secret test-mixed default true true "e2e-test-ns,e2e-stage-*" "mixed-data"
create_test_namespace e2e-stage-2
verify_mirrors_exist secret test-mixed e2e-test-ns e2e-stage-1 e2e-stage-2
# Remove explicit, keep pattern
update_source_annotations secret test-mixed default true "e2e-stage-*"
verify_orphan_cleanup secret test-mixed e2e-test-ns
verify_mirrors_exist secret test-mixed e2e-stage-1 e2e-stage-2
complete_test_scenario "21" "pass"
#===============================================================================
# SCENARIO 22: Sync annotation removed completely
#===============================================================================
run_test_scenario "22: Remove sync annotation completely"
verify_mirrors_exist secret test-mixed e2e-stage-1 e2e-stage-2
update_source_annotations secret test-mixed default "" ""
verify_orphan_cleanup secret test-mixed e2e-stage-1 e2e-stage-2
complete_test_scenario "22" "pass"
#===============================================================================
# SCENARIO 23: Traefik Middleware CRD (test generic CRD support)
#===============================================================================
run_test_scenario "23: Traefik Middleware CRD with spec updates"
# Create Traefik Middleware CRD manually (CRDs aren't supported by create_source helper)
cat <<EOF | kubectl apply -f - >/dev/null 2>&1
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: test-middleware
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
test-resource: e2e
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "e2e-app-1,e2e-app-2"
spec:
basicAuth:
secret: auth-secret-v1
removeHeader: false
headers:
customRequestHeaders:
X-Test-Header: "test-value-v1"
EOF
sleep 5
# Verify mirrors created with correct spec
verify_mirrors_exist middleware.traefik.io test-middleware e2e-app-1 e2e-app-2
# Verify mirror spec content (check one of the spec fields)
app1_secret=$(kubectl get middleware test-middleware -n e2e-app-1 -o jsonpath='{.spec.basicAuth.secret}' 2>/dev/null || echo "")
if [ "$app1_secret" = "auth-secret-v1" ]; then
log_success "Mirror spec in e2e-app-1 matches expected: auth-secret-v1"
((PASS_COUNT++))
else
log_fail "Mirror spec in e2e-app-1 does not match. Expected: auth-secret-v1, Got: $app1_secret"
((FAIL_COUNT++))
fi
# Update the Middleware spec
cat <<EOF | kubectl apply -f - >/dev/null 2>&1
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: test-middleware
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
test-resource: e2e
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "e2e-app-1,e2e-app-2"
spec:
basicAuth:
secret: auth-secret-v2-updated
removeHeader: true
headers:
customRequestHeaders:
X-Test-Header: "test-value-v2-updated"
X-New-Header: "new-value"
EOF
sleep 20
# Verify mirror spec was updated
app1_secret_updated=$(kubectl get middleware test-middleware -n e2e-app-1 -o jsonpath='{.spec.basicAuth.secret}' 2>/dev/null || echo "")
if [ "$app1_secret_updated" = "auth-secret-v2-updated" ]; then
log_success "Mirror spec in e2e-app-1 updated correctly: auth-secret-v2-updated"
((PASS_COUNT++))
else
log_fail "Mirror spec in e2e-app-1 not updated. Expected: auth-secret-v2-updated, Got: $app1_secret_updated"
((FAIL_COUNT++))
fi
app1_header=$(kubectl get middleware test-middleware -n e2e-app-1 -o jsonpath='{.spec.headers.customRequestHeaders.X-New-Header}' 2>/dev/null || echo "")
if [ "$app1_header" = "new-value" ]; then
log_success "Mirror spec headers in e2e-app-1 updated correctly: new-value"
((PASS_COUNT++))
else
log_fail "Mirror spec headers in e2e-app-1 not updated. Expected: new-value, Got: $app1_header"
((FAIL_COUNT++))
fi
# Change target namespaces pattern
kubectl annotate middleware test-middleware -n default \
kubemirror.raczylo.com/target-namespaces="e2e-db-*" --overwrite >/dev/null 2>&1
sleep 10
# Verify old mirrors cleaned up
verify_orphan_cleanup middleware.traefik.io test-middleware e2e-app-1 e2e-app-2
# Verify new mirrors created
verify_mirrors_exist middleware.traefik.io test-middleware e2e-db-1 e2e-db-2
# Clean up CRD
kubectl delete middleware test-middleware -n default --ignore-not-found=true >/dev/null 2>&1
complete_test_scenario "23" "pass"
#===============================================================================
# Final Summary
#===============================================================================
echo ""
echo "======================================"
echo "Comprehensive E2E Test Complete"
echo "======================================"
print_summary
+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
}
+4
View File
@@ -50,6 +50,10 @@ type Config struct {
EnableAllKeyword bool
// DryRun mode logs what would happen without actually making changes
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.
+31 -7
View File
@@ -302,18 +302,42 @@ func updateMirrorAnnotations(mirror metav1.Object, source runtime.Object, source
}
// 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 {
m := mirror.(*unstructured.Unstructured)
s := source.(*unstructured.Unstructured)
// Update spec
sourceSpec, found, err := unstructured.NestedMap(s.Object, "spec")
if err != nil {
return fmt.Errorf("failed to get source spec: %w", err)
// Fields to skip (Kubernetes-managed fields, not user content)
// These are managed by Kubernetes API server or controllers
skipFields := map[string]bool{
// 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 {
return fmt.Errorf("failed to set mirror spec: %w", err)
// Copy all content fields from source to mirror
// 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
}
}
+124
View File
@@ -368,6 +368,130 @@ func TestUpdateMirror_ConfigMap(t *testing.T) {
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) {
tests := []struct {
obj metav1.Object
+20
View File
@@ -55,3 +55,23 @@ func (k *KubernetesNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Conte
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
}
+93 -8
View File
@@ -36,6 +36,7 @@ type SourceReconciler struct {
Filter *filter.NamespaceFilter
NamespaceLister NamespaceLister
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.
@@ -43,20 +44,83 @@ type SourceReconciler struct {
type NamespaceLister interface {
ListNamespaces(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=configmaps,verbs=get;list;watch;create;update;patch;delete
// +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.
func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx).WithValues("namespace", req.Namespace, "name", req.Name)
// Fetch the source resource as unstructured (works for all resource types)
source := &unstructured.Unstructured{}
source.SetGroupVersionKind(r.GVK) // Set the GVK so the client knows what to fetch
if err := r.Get(ctx, req.NamespacedName, source); err != nil {
// Fetch the source resource with optional freshness verification
source, err := r.getSourceWithFreshness(ctx, req.NamespacedName, r.GVK)
if err != nil {
if errors.IsNotFound(err) {
// Resource deleted - nothing to do
return ctrl.Result{}, nil
@@ -216,10 +280,24 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
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 {
// Mirror exists - check if it's managed by us
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
}
@@ -230,7 +308,7 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
}
if !needsSync {
logger.V(1).Info("mirror is up to date")
logger.V(2).Info("mirror is up to date")
return nil
}
@@ -243,7 +321,7 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
return fmt.Errorf("failed to update mirror in cluster: %w", err)
}
logger.Info("mirror updated")
logger.V(1).Info("mirror updated")
return nil
}
@@ -257,7 +335,7 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
return fmt.Errorf("failed to create mirror in cluster: %w", err)
}
logger.Info("mirror created")
logger.V(1).Info("mirror created")
return nil
}
@@ -407,11 +485,18 @@ func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceOb
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,
sourceObj.GetNamespace(),
r.Filter,
)
+9
View File
@@ -129,6 +129,11 @@ func (m *MockNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([
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) {
tests := []struct {
obj metav1.Object
@@ -277,6 +282,7 @@ func TestSourceReconciler_resolveTargetNamespaces(t *testing.T) {
if tt.expectListCalls {
mockLister.On("ListNamespaces", mock.Anything).Return(tt.allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(tt.allowMirrorsNamespaces, nil)
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
}
r := &SourceReconciler{
@@ -441,6 +447,7 @@ func BenchmarkResolveTargetNamespaces(b *testing.B) {
}
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(allNamespaces[:50], nil)
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
r := &SourceReconciler{
Config: &config.Config{},
@@ -616,6 +623,7 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(allowMirrorsNamespaces, nil)
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
// Mock Get for source
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "test-secret"}, mock.Anything).
@@ -739,6 +747,7 @@ func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T)
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return([]string{}, nil)
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
// Mock Get for source
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
// - allNamespaces: list of all namespaces in cluster
// - allowMirrorsNamespaces: namespaces with allow-mirrors label
// - optOutNamespaces: namespaces with allow-mirrors="false" (explicitly opted out)
// - sourceNamespace: exclude this namespace to prevent self-copy
// - filter: namespace filter for exclusions
//
@@ -113,6 +114,7 @@ func ResolveTargetNamespaces(
patterns []string,
allNamespaces []string,
allowMirrorsNamespaces []string,
optOutNamespaces []string,
sourceNamespace string,
filter *NamespaceFilter,
) []string {
@@ -120,21 +122,30 @@ func ResolveTargetNamespaces(
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
targetMap := make(map[string]bool)
for _, pattern := range patterns {
switch pattern {
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 {
if ns != sourceNamespace && filter.IsAllowed(ns) {
if ns != sourceNamespace && filter.IsAllowed(ns) && !optOutMap[ns] {
targetMap[ns] = true
}
}
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 {
if ns != sourceNamespace && filter.IsAllowed(ns) {
targetMap[ns] = true
+7
View File
@@ -318,6 +318,7 @@ func TestResolveTargetNamespaces(t *testing.T) {
tt.patterns,
tt.allNamespaces,
tt.allowMirrorsNamespaces,
[]string{}, // optOutNamespaces - empty for these tests
tt.sourceNamespace,
tt.filter,
)
@@ -342,6 +343,7 @@ func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
[]string{"all"},
[]string{},
[]string{},
[]string{}, // optOutNamespaces
"default",
NewNamespaceFilter([]string{}, []string{}),
)
@@ -354,6 +356,7 @@ func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
[]string{"[invalid"},
[]string{"app1"},
[]string{},
[]string{}, // optOutNamespaces
"default",
NewNamespaceFilter([]string{}, []string{}),
)
@@ -366,6 +369,7 @@ func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
[]string{"all"},
[]string{"app1", "app2", "app3"},
[]string{},
[]string{}, // optOutNamespaces
"default",
strictFilter,
)
@@ -541,6 +545,7 @@ func BenchmarkResolveTargetNamespaces(b *testing.B) {
tt.patterns,
allNamespaces,
allowMirrorsNamespaces,
[]string{}, // optOutNamespaces
"default",
filter,
)
@@ -566,6 +571,7 @@ func BenchmarkResolveTargetNamespaces_LargeScale(b *testing.B) {
[]string{constants.TargetNamespacesAll},
allNamespaces,
allowMirrorsNamespaces,
[]string{}, // optOutNamespaces
"default",
filter,
)
@@ -579,6 +585,7 @@ func BenchmarkResolveTargetNamespaces_LargeScale(b *testing.B) {
[]string{"namespace-*"},
allNamespaces,
allowMirrorsNamespaces,
[]string{}, // optOutNamespaces
"default",
filter,
)
+4 -2
View File
@@ -10,6 +10,8 @@ import (
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
)
// ComputeContentHash computes a SHA256 hash of the resource's actual content.
@@ -109,7 +111,7 @@ func NeedsSync(source, target runtime.Object, targetAnnotations map[string]strin
// Layer 1: Generation-based check (for resources that support it)
sourceGen := getGeneration(source)
if sourceGen > 0 {
targetSourceGen := targetAnnotations["source-generation"]
targetSourceGen := targetAnnotations[constants.AnnotationSourceGeneration]
if fmt.Sprintf("%d", sourceGen) != targetSourceGen {
return true, nil // Generation changed
}
@@ -121,7 +123,7 @@ func NeedsSync(source, target runtime.Object, targetAnnotations map[string]strin
return false, fmt.Errorf("failed to compute source hash: %w", err)
}
targetSourceHash := targetAnnotations["source-content-hash"]
targetSourceHash := targetAnnotations[constants.AnnotationSourceContentHash]
if sourceHash != targetSourceHash {
return true, nil // Content changed
}
+6 -5
View File
@@ -3,6 +3,7 @@ package hash
import (
"testing"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
@@ -374,8 +375,8 @@ func TestNeedsSync(t *testing.T) {
},
target: &unstructured.Unstructured{},
targetAnnotations: map[string]string{
"source-generation": "3",
"source-content-hash": "abc123",
constants.AnnotationSourceGeneration: "3",
constants.AnnotationSourceContentHash: "abc123",
},
want: true,
wantError: false,
@@ -387,8 +388,8 @@ func TestNeedsSync(t *testing.T) {
},
target: &corev1.Secret{},
targetAnnotations: map[string]string{
"source-generation": "0",
"source-content-hash": mustComputeHash(t, &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}),
constants.AnnotationSourceGeneration: "0",
constants.AnnotationSourceContentHash: mustComputeHash(t, &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}),
},
want: false,
wantError: false,
@@ -400,7 +401,7 @@ func TestNeedsSync(t *testing.T) {
},
target: &corev1.ConfigMap{},
targetAnnotations: map[string]string{
"source-content-hash": "oldhash",
constants.AnnotationSourceContentHash: "oldhash",
},
want: true,
wantError: false,