diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 42fbdc0..835e234 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,7 +16,33 @@ permissions: packages: write jobs: + e2e-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ">=1.25" + + - name: Install dependencies + run: go mod download + + - uses: engineerd/setup-kind@v0.6.2 + + - name: Run e2e tests + run: | + # rename context to docker-desktop + kubectl config rename-context "$(kubectl config current-context)" docker-desktop + cd e2e + ./run-all-tests.sh + release: + needs: e2e-tests uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main with: go-version: ">=1.25" diff --git a/cmd/kubemirror/main.go b/cmd/kubemirror/main.go index c926f33..b48e381 100644 --- a/cmd/kubemirror/main.go +++ b/cmd/kubemirror/main.go @@ -219,8 +219,8 @@ func main() { "kind", gvk.Kind, ) - // Create a reconciler instance for this specific resource type - reconciler := &controller.SourceReconciler{ + // Create a source reconciler instance for this specific resource type + sourceReconciler := &controller.SourceReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Config: cfg, @@ -230,15 +230,30 @@ func main() { APIReader: mgr.GetAPIReader(), // Direct API reader (bypasses cache) } - if err = reconciler.SetupWithManagerForResourceType(mgr, gvk); err != nil { - setupLog.Error(err, "unable to create controller", + if err = sourceReconciler.SetupWithManagerForResourceType(mgr, gvk); err != nil { + setupLog.Error(err, "unable to create source controller", + "resourceType", rt.String(), + ) + os.Exit(1) + } + + // Create a mirror reconciler instance for orphan detection + // This watches mirrored resources (with managed-by label) and verifies their source still exists + mirrorReconciler := &controller.MirrorReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + GVK: gvk, + } + + if err = mirrorReconciler.SetupWithManager(mgr, gvk); err != nil { + setupLog.Error(err, "unable to create mirror controller", "resourceType", rt.String(), ) os.Exit(1) } } - setupLog.Info("registered source controllers", "count", len(cfg.MirroredResourceTypes)) + setupLog.Info("registered source and mirror controllers", "count", len(cfg.MirroredResourceTypes)) // Register namespace reconciler to watch for new namespaces and label changes namespaceReconciler := &controller.NamespaceReconciler{ diff --git a/e2e/README.md b/e2e/README.md index 4f6c269..8f05bf4 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -19,8 +19,9 @@ The test suite uses a **data-driven framework** approach where test scenarios ar 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) +3. **test-comprehensive.sh**: Comprehensive test scenarios using the framework (supports selective execution) +4. **test-parallel.sh**: Parallel test runner for faster execution (batches independent tests) +5. **run-all-tests.sh**: Main test runner (builds binary, starts controller, runs tests) ### Test Framework Functions @@ -47,7 +48,7 @@ verify_orphan_cleanup ... ## Comprehensive Test Suite -The comprehensive test suite (`test-comprehensive.sh`) covers **22 systematic scenarios**: +The comprehensive test suite (`test-comprehensive.sh`) covers **30 systematic scenarios**: ### Source Lifecycle Scenarios @@ -88,11 +89,23 @@ The comprehensive test suite (`test-comprehensive.sh`) covers **22 systematic sc ### Resource Types -All scenarios tested with both **Secrets** and **ConfigMaps**. +23. **Mixed resource types**: ConfigMaps alongside Secrets +24. **Custom Resource (Traefik Middleware)**: CRD mirroring + +### Transformation Scenarios (24-30) + +25. **Static value transformation**: Replace data values with static strings +26. **Template transformation**: Use Go templates with context variables +27. **Merge transformation**: Merge new data into existing fields +28. **Delete transformation**: Remove specific fields +29. **Multiple transformations**: Combine multiple rules +30. **Strict mode**: Fail on transformation errors vs skip + +All basic scenarios tested with both **Secrets** and **ConfigMaps**. ## Running Tests -### Run Complete Test Suite +### Run Complete Test Suite (Sequential) ```bash cd e2e @@ -103,22 +116,55 @@ 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) +4. Run comprehensive test scenarios (all 30 scenarios sequentially) 5. Report detailed results with pass/fail for each 6. Clean up all resources automatically -### Run Individual Test Scenarios +**Performance**: ~5-7 minutes for all 30 scenarios + +### Run Complete Test Suite (Parallel) - **FASTER** ⚡ + +```bash +cd e2e +# Start controller first +../kubemirror --max-targets=100 --worker-threads=5 > /tmp/kubemirror-test.log 2>&1 & + +# Run tests in parallel batches +./test-parallel.sh +``` + +This runs independent tests in parallel batches: +- **Sequential**: Scenarios 1-11 (core lifecycle - must run sequentially) +- **Parallel Batch 1**: Scenarios 12-15 (namespace labels) +- **Parallel Batch 2**: Scenarios 16-19 (deletion scenarios) +- **Parallel Batch 3**: Scenarios 20-23 (mixed resources) +- **Parallel Batch 4**: Scenarios 24-27 (transformations part 1) +- **Parallel Batch 5**: Scenarios 28-30 (transformations part 2) + +**Performance**: ~3-4 minutes (40-50% faster than sequential) + +### Run Selective Scenarios + +Run only specific scenarios for faster iteration during development: ```bash # Must have KubeMirror controller running first cd /Users/nvm/Documents/projects/private/kube-mirror ./kubemirror --max-targets=100 --worker-threads=5 > /tmp/kubemirror-test.log 2>&1 & -# Then run the test +# Run only transformation tests (scenarios 24-30) cd e2e -./test-comprehensive.sh +./test-comprehensive.sh 24 25 26 27 28 29 30 + +# Run only specific scenarios +./test-comprehensive.sh 1 2 3 + +# Run single scenario for debugging +./test-comprehensive.sh 24 ``` +**Performance**: <1 minute for a few scenarios + ## Test Output Each test produces colored output: @@ -162,12 +208,17 @@ All tests passed! ## Test Resources -Tests create temporary resources: -- **Namespaces**: `e2e-*` prefixed -- **Secrets**: `test-*` prefixed in default namespace -- **ConfigMaps**: `test-*` prefixed in default namespace +Tests create temporary resources with clear naming for isolation: +- **Source Namespace**: `kubemirror-e2e-source` (dedicated namespace for all test source resources) +- **Target Namespaces**: `kubemirror-e2e-*` prefixed (ns-1, ns-2, app-1, db-1, etc.) +- **Secrets**: `test-*` prefixed in source namespace +- **ConfigMaps**: `test-*` prefixed in source namespace +- **CRDs**: Traefik Middleware resources for CRD testing -All resources are cleaned up automatically on test completion. +All resources are cleaned up automatically on test completion, including: +- Automatic finalizer removal from source resources (prevents hanging deletions) +- Cascade deletion of all target namespaces +- Cleanup on test interruption (SIGINT/SIGTERM) ## Troubleshooting @@ -292,7 +343,9 @@ verify_orphan_cleanup secret my-secret orphan-ns1 orphan-ns2 | 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** | +| Resource types | 2 | Mixed ConfigMaps, Custom Resource (Traefik Middleware) | +| Transformations | 7 | Static value, template, merge, delete, multiple, strict mode | +| **Total** | **30** | **Comprehensive coverage with multiple resource types** | ## Test Methodology @@ -372,23 +425,44 @@ 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 Execution Times +- **Sequential execution** (test-comprehensive.sh): ~5-7 minutes for all 30 scenarios +- **Parallel execution** (test-parallel.sh): ~3-4 minutes for all 30 scenarios (40-50% faster) +- **Selective execution** (few scenarios): <1 minute +- **Controller startup**: ~10 seconds +- **Resource reconciliation**: typically <5 seconds per operation + +### Test Coverage +- **Total scenarios**: 30 comprehensive scenarios +- **Total assertions**: 100+ across all scenarios +- **Resource types tested**: Secrets, ConfigMaps, Traefik Middlewares (CRDs) +- **Each scenario includes**: Setup, action, verification, and cleanup phases + +### Optimization Tips +- Use `test-parallel.sh` for full test runs (40-50% faster) +- Use selective execution during development: `./test-comprehensive.sh 24 25 26` +- Run only affected scenarios after code changes +- Parallel execution is safe - batches ensure test independence ## Test Isolation and Cleanup - **Automatic cleanup**: All resources cleaned up via trap handlers -- **Namespace isolation**: Tests use `e2e-*` prefixed namespaces -- **Sequential execution**: Tests run sequentially to avoid race conditions +- **Namespace isolation**: + - Dedicated source namespace: `kubemirror-e2e-source` + - Target namespaces: `kubemirror-e2e-*` prefixed + - No pollution of `default` namespace +- **Execution modes**: + - Sequential: All scenarios run in order (test-comprehensive.sh with no args) + - Parallel: Independent scenarios batched (test-parallel.sh) + - Selective: Run specific scenarios (test-comprehensive.sh 24 25 26) - **Idempotent**: Tests can be re-run without manual cleanup -- **Resource labeling**: Test resources labeled for easy identification +- **Resource labeling**: Test resources labeled `test-resource: e2e` for easy identification +- **Finalizer handling**: Automatic finalizer removal prevents stuck resource deletions ## Known Limitations - Tests assume clean docker-desktop cluster (or equivalent local cluster) - Some scenarios require waiting for reconciliation (30s default timeout) -- Tests are sequential (not parallel) to ensure deterministic behavior - Controller must be stopped between runs if running manually (run-all-tests.sh handles this) +- Parallel execution requires sufficient cluster resources (5-6 tests may run concurrently) +- Some scenarios depend on previous state (scenarios 1-11 must run sequentially) diff --git a/e2e/test-comprehensive.sh b/e2e/test-comprehensive.sh index 77539dd..bbcc506 100755 --- a/e2e/test-comprehensive.sh +++ b/e2e/test-comprehensive.sh @@ -2,6 +2,19 @@ # Comprehensive E2E Test Suite for KubeMirror # Tests all scenarios systematically using the test framework +# +# Usage: +# ./test-comprehensive.sh # Run all 30 scenarios sequentially +# ./test-comprehensive.sh 24 25 26 # Run only scenarios 24, 25, and 26 +# ./test-comprehensive.sh 1 2 3 # Run only scenarios 1, 2, and 3 +# +# For parallel execution (faster): +# ./test-parallel.sh # Runs tests in parallel batches +# +# Performance: +# - Sequential (all): ~5-7 minutes +# - Parallel: ~3-4 minutes +# - Selective (few scenarios): <1 minute SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/common.sh" @@ -9,53 +22,105 @@ source "$SCRIPT_DIR/test-framework.sh" TEST_NAME="Comprehensive E2E Tests" +# Dedicated namespace for test source resources (NOT default!) +# Using kubemirror- prefix for clear identification and isolation +E2E_SOURCE_NS="kubemirror-e2e-source" + # 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 + # Remove finalizers from source resources before deleting + # This prevents resources from getting stuck in Terminating state + for resource in $(kubectl get secret,configmap -n "$E2E_SOURCE_NS" -l test-resource=e2e -o name 2>/dev/null); do + kubectl patch "$resource" -n "$E2E_SOURCE_NS" --type=json -p='[{"op":"remove","path":"/metadata/finalizers"}]' 2>/dev/null || true + done + for resource in $(kubectl get middleware.traefik.io -n "$E2E_SOURCE_NS" -l test-resource=e2e -o name 2>/dev/null); do + kubectl patch "$resource" -n "$E2E_SOURCE_NS" --type=json -p='[{"op":"remove","path":"/metadata/finalizers"}]' 2>/dev/null || true + done + + # Delete all test secrets, configmaps, and CRDs from source namespace + kubectl delete secret,configmap -n "$E2E_SOURCE_NS" -l test-resource=e2e --ignore-not-found=true 2>/dev/null || true + kubectl delete middleware.traefik.io -n "$E2E_SOURCE_NS" -l test-resource=e2e --ignore-not-found=true 2>/dev/null || true # Delete all test namespaces + kubectl delete namespace "$E2E_SOURCE_NS" --ignore-not-found=true --wait=false 2>/dev/null || true + for i in {1..5}; do - kubectl delete namespace "e2e-ns-$i" --ignore-not-found=true --wait=false 2>/dev/null || true + kubectl delete namespace "kubemirror-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 + kubectl delete namespace "kubemirror-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 + kubectl delete namespace kubemirror-e2e-labeled kubemirror-e2e-unlabeled kubemirror-e2e-test-ns --ignore-not-found=true --wait=false 2>/dev/null || true sleep 5 } trap cleanup EXIT +# Parse command-line arguments for selective scenario execution +SCENARIOS_TO_RUN=() +if [ $# -gt 0 ]; then + SCENARIOS_TO_RUN=("$@") + log_info "Running specific scenarios: ${SCENARIOS_TO_RUN[*]}" +else + log_info "Running all scenarios (no filter specified)" +fi + +# Helper function to check if a scenario should run +should_run_scenario() { + local scenario_num=$1 + + # If no filter specified, run all scenarios + if [ ${#SCENARIOS_TO_RUN[@]} -eq 0 ]; then + return 0 + fi + + # Check if this scenario is in the list + for num in "${SCENARIOS_TO_RUN[@]}"; do + if [ "$num" = "$scenario_num" ]; then + return 0 + fi + done + + return 1 +} + log_info "Starting $TEST_NAME" # Clean up before starting cleanup sleep 3 +# Create dedicated source namespace for all test resources +log_info "Creating dedicated source namespace: $E2E_SOURCE_NS" +kubectl create namespace "$E2E_SOURCE_NS" 2>/dev/null || true +sleep 2 + #=============================================================================== # SCENARIO 1: Source without labels/annotations #=============================================================================== +if ! should_run_scenario 1; then + continue +fi + run_test_scenario "1: Source created without labels or annotations" -create_test_namespace e2e-ns-1 -create_test_namespace e2e-ns-2 +create_test_namespace kubemirror-e2e-ns-1 +create_test_namespace kubemirror-e2e-ns-2 -create_source secret test-no-labels-1 default false false "" "data-v1" +create_source secret test-no-labels-1 "$E2E_SOURCE_NS" 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 +verify_mirrors_not_exist secret test-no-labels-1 kubemirror-e2e-ns-1 kubemirror-e2e-ns-2 complete_test_scenario "1" "pass" @@ -63,14 +128,18 @@ complete_test_scenario "1" "pass" # SCENARIO 2: Labels added to source #=============================================================================== +if ! should_run_scenario 2; then + continue +fi + run_test_scenario "2: Add enabled label to source (no sync annotation yet)" -update_source_labels secret test-no-labels-1 default true +update_source_labels secret test-no-labels-1 "$E2E_SOURCE_NS" true sleep 3 # Still no mirrors (sync annotation required) -verify_mirrors_not_exist secret test-no-labels-1 e2e-ns-1 e2e-ns-2 +verify_mirrors_not_exist secret test-no-labels-1 kubemirror-e2e-ns-1 kubemirror-e2e-ns-2 complete_test_scenario "2" "pass" @@ -78,13 +147,17 @@ complete_test_scenario "2" "pass" # SCENARIO 3: Sync annotation added to labeled source #=============================================================================== +if ! should_run_scenario 3; then + continue +fi + 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" +update_source_annotations secret test-no-labels-1 "$E2E_SOURCE_NS" true "kubemirror-e2e-ns-1,kubemirror-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" +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-ns-1 kubemirror-e2e-ns-2 +verify_mirror_data secret test-no-labels-1 "$E2E_SOURCE_NS" kubemirror-e2e-ns-1 "data-v1" complete_test_scenario "3" "pass" @@ -92,15 +165,19 @@ complete_test_scenario "3" "pass" # SCENARIO 4: Source content modified #=============================================================================== +if ! should_run_scenario 4; then + continue +fi + run_test_scenario "4: Modify source data content" -update_source_data secret test-no-labels-1 default "data-v2-updated" +update_source_data secret test-no-labels-1 "$E2E_SOURCE_NS" "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" +verify_mirror_data secret test-no-labels-1 "$E2E_SOURCE_NS" kubemirror-e2e-ns-1 "data-v2-updated" +verify_mirror_data secret test-no-labels-1 "$E2E_SOURCE_NS" kubemirror-e2e-ns-2 "data-v2-updated" complete_test_scenario "4" "pass" @@ -108,15 +185,19 @@ complete_test_scenario "4" "pass" # SCENARIO 5: Add namespace to target list #=============================================================================== +if ! should_run_scenario 5; then + continue +fi + run_test_scenario "5: Add namespace to target-namespaces list" -create_test_namespace e2e-ns-3 +create_test_namespace kubemirror-e2e-ns-3 -update_source_annotations secret test-no-labels-1 default true "e2e-ns-1,e2e-ns-2,e2e-ns-3" +update_source_annotations secret test-no-labels-1 "$E2E_SOURCE_NS" true "kubemirror-e2e-ns-1,kubemirror-e2e-ns-2,kubemirror-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" +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-ns-1 kubemirror-e2e-ns-2 kubemirror-e2e-ns-3 +verify_mirror_data secret test-no-labels-1 "$E2E_SOURCE_NS" kubemirror-e2e-ns-3 "data-v2-updated" complete_test_scenario "5" "pass" @@ -124,15 +205,19 @@ complete_test_scenario "5" "pass" # SCENARIO 6: Remove namespace from target list #=============================================================================== +if ! should_run_scenario 6; then + continue +fi + 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" +update_source_annotations secret test-no-labels-1 "$E2E_SOURCE_NS" true "kubemirror-e2e-ns-1,kubemirror-e2e-ns-2" -# Orphaned mirror in e2e-ns-3 should be deleted -verify_orphan_cleanup secret test-no-labels-1 e2e-ns-3 +# Orphaned mirror in kubemirror-e2e-ns-3 should be deleted +verify_orphan_cleanup secret test-no-labels-1 kubemirror-e2e-ns-3 # Others still exist -verify_mirrors_exist secret test-no-labels-1 e2e-ns-1 e2e-ns-2 +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-ns-1 kubemirror-e2e-ns-2 complete_test_scenario "6" "pass" @@ -140,22 +225,26 @@ complete_test_scenario "6" "pass" # SCENARIO 7: Change target-namespaces from explicit list to pattern #=============================================================================== +if ! should_run_scenario 7; then + continue +fi + 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 +create_test_namespace kubemirror-e2e-app-1 +create_test_namespace kubemirror-e2e-app-2 +create_test_namespace kubemirror-e2e-db-1 -update_source_annotations secret test-no-labels-1 default true "e2e-app-*" +update_source_annotations secret test-no-labels-1 "$E2E_SOURCE_NS" true "kubemirror-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 remove mirrors from kubemirror-e2e-ns-1, kubemirror-e2e-ns-2 +verify_orphan_cleanup secret test-no-labels-1 kubemirror-e2e-ns-1 kubemirror-e2e-ns-2 -# Should create mirrors in e2e-app-* -verify_mirrors_exist secret test-no-labels-1 e2e-app-1 e2e-app-2 +# Should create mirrors in kubemirror-e2e-app-* +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-app-1 kubemirror-e2e-app-2 -# Should NOT create in e2e-db-1 -verify_mirrors_not_exist secret test-no-labels-1 e2e-db-1 +# Should NOT create in kubemirror-e2e-db-1 +verify_mirrors_not_exist secret test-no-labels-1 kubemirror-e2e-db-1 complete_test_scenario "7" "pass" @@ -163,21 +252,25 @@ complete_test_scenario "7" "pass" # SCENARIO 8: Multiple patterns #=============================================================================== +if ! should_run_scenario 8; then + continue +fi + run_test_scenario "8: Multiple patterns in target-namespaces" -create_test_namespace e2e-db-2 -create_test_namespace e2e-stage-1 +create_test_namespace kubemirror-e2e-db-2 +create_test_namespace kubemirror-e2e-stage-1 -update_source_annotations secret test-no-labels-1 default true "e2e-app-*,e2e-db-*" +update_source_annotations secret test-no-labels-1 "$E2E_SOURCE_NS" true "kubemirror-e2e-app-*,kubemirror-e2e-db-*" -# Should add mirrors to e2e-db-* -verify_mirrors_exist secret test-no-labels-1 e2e-db-1 e2e-db-2 +# Should add mirrors to kubemirror-e2e-db-* +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-db-1 kubemirror-e2e-db-2 -# Should still have e2e-app-* -verify_mirrors_exist secret test-no-labels-1 e2e-app-1 e2e-app-2 +# Should still have kubemirror-e2e-app-* +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-app-1 kubemirror-e2e-app-2 -# Should NOT have e2e-stage-* -verify_mirrors_not_exist secret test-no-labels-1 e2e-stage-1 +# Should NOT have kubemirror-e2e-stage-* +verify_mirrors_not_exist secret test-no-labels-1 kubemirror-e2e-stage-1 complete_test_scenario "8" "pass" @@ -185,12 +278,16 @@ complete_test_scenario "8" "pass" # SCENARIO 9: Sync annotation set to false #=============================================================================== +if ! should_run_scenario 9; then + continue +fi + run_test_scenario "9: Set sync annotation to false" -update_source_annotations secret test-no-labels-1 default false "" +update_source_annotations secret test-no-labels-1 "$E2E_SOURCE_NS" 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 +verify_orphan_cleanup secret test-no-labels-1 kubemirror-e2e-app-1 kubemirror-e2e-app-2 kubemirror-e2e-db-1 kubemirror-e2e-db-2 complete_test_scenario "9" "pass" @@ -198,20 +295,24 @@ complete_test_scenario "9" "pass" # SCENARIO 10: Enabled label set to false #=============================================================================== +if ! should_run_scenario 10; then + continue +fi + 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" +update_source_annotations secret test-no-labels-1 "$E2E_SOURCE_NS" true "kubemirror-e2e-app-1" -verify_mirrors_exist secret test-no-labels-1 e2e-app-1 +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-app-1 # Now disable via label -update_source_labels secret test-no-labels-1 default false +update_source_labels secret test-no-labels-1 "$E2E_SOURCE_NS" false sleep 3 # Mirror should be removed (label filtering) -verify_orphan_cleanup secret test-no-labels-1 e2e-app-1 +verify_orphan_cleanup secret test-no-labels-1 kubemirror-e2e-app-1 complete_test_scenario "10" "pass" @@ -219,22 +320,26 @@ complete_test_scenario "10" "pass" # SCENARIO 11: Pattern with new namespace created #=============================================================================== +if ! should_run_scenario 11; then + continue +fi + 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-*" +update_source_labels secret test-no-labels-1 "$E2E_SOURCE_NS" true +update_source_annotations secret test-no-labels-1 "$E2E_SOURCE_NS" true "kubemirror-e2e-prod-*" -create_test_namespace e2e-prod-1 +create_test_namespace kubemirror-e2e-prod-1 # Should automatically create mirror in new namespace -verify_mirrors_exist secret test-no-labels-1 e2e-prod-1 +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-prod-1 # Create another matching namespace -create_test_namespace e2e-prod-2 +create_test_namespace kubemirror-e2e-prod-2 # Should also get the mirror -verify_mirrors_exist secret test-no-labels-1 e2e-prod-2 +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-prod-2 complete_test_scenario "11" "pass" @@ -242,17 +347,21 @@ complete_test_scenario "11" "pass" # SCENARIO 12: 'all' keyword without namespace label (opt-OUT model) #=============================================================================== +if ! should_run_scenario 12; then + continue +fi + 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_source configmap test-all-no-label "$E2E_SOURCE_NS" true true "all" "all-data-v1" -create_test_namespace e2e-unlabeled +create_test_namespace kubemirror-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" +verify_mirrors_exist configmap test-all-no-label kubemirror-e2e-unlabeled +verify_mirror_data configmap test-all-no-label "$E2E_SOURCE_NS" kubemirror-e2e-unlabeled "all-data-v1" complete_test_scenario "12" "pass" @@ -260,14 +369,18 @@ complete_test_scenario "12" "pass" # SCENARIO 13: Set allow-mirrors=false to opt-out #=============================================================================== +if ! should_run_scenario 13; then + continue +fi + run_test_scenario "13: Set allow-mirrors=false on namespace (explicit opt-OUT)" -update_namespace_labels e2e-unlabeled false +update_namespace_labels kubemirror-e2e-unlabeled false sleep 5 # Mirror should be deleted (explicit opt-OUT) -verify_orphan_cleanup configmap test-all-no-label e2e-unlabeled +verify_orphan_cleanup configmap test-all-no-label kubemirror-e2e-unlabeled complete_test_scenario "13" "pass" @@ -275,15 +388,19 @@ complete_test_scenario "13" "pass" # SCENARIO 14: Change allow-mirrors from false to true #=============================================================================== +if ! should_run_scenario 14; then + continue +fi + run_test_scenario "14: Change allow-mirrors label from false to true" -update_namespace_labels e2e-unlabeled true +update_namespace_labels kubemirror-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" +verify_mirrors_exist configmap test-all-no-label kubemirror-e2e-unlabeled +verify_mirror_data configmap test-all-no-label "$E2E_SOURCE_NS" kubemirror-e2e-unlabeled "all-data-v1" complete_test_scenario "14" "pass" @@ -291,15 +408,19 @@ complete_test_scenario "14" "pass" # SCENARIO 15: Remove allow-mirrors label (back to default opt-IN) #=============================================================================== +if ! should_run_scenario 15; then + continue +fi + run_test_scenario "15: Remove allow-mirrors label from namespace" -update_namespace_labels e2e-unlabeled "" +update_namespace_labels kubemirror-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" +verify_mirrors_exist configmap test-all-no-label kubemirror-e2e-unlabeled +verify_mirror_data configmap test-all-no-label "$E2E_SOURCE_NS" kubemirror-e2e-unlabeled "all-data-v1" complete_test_scenario "15" "pass" @@ -307,20 +428,24 @@ complete_test_scenario "15" "pass" # SCENARIO 16: Target namespace deleted #=============================================================================== +if ! should_run_scenario 16; then + continue +fi + 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" +create_test_namespace kubemirror-e2e-ns-4 +update_source_annotations secret test-no-labels-1 "$E2E_SOURCE_NS" true "kubemirror-e2e-ns-4,kubemirror-e2e-prod-1" -verify_mirrors_exist secret test-no-labels-1 e2e-ns-4 e2e-prod-1 +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-ns-4 kubemirror-e2e-prod-1 # Delete one of the target namespaces -delete_namespace e2e-ns-4 +delete_namespace kubemirror-e2e-ns-4 sleep 3 # Other mirror should still exist -verify_mirrors_exist secret test-no-labels-1 e2e-prod-1 +verify_mirrors_exist secret test-no-labels-1 kubemirror-e2e-prod-1 complete_test_scenario "16" "pass" @@ -328,16 +453,20 @@ complete_test_scenario "16" "pass" # SCENARIO 17: Recreate deleted target namespace #=============================================================================== +if ! should_run_scenario 17; then + continue +fi + run_test_scenario "17: Recreate deleted target namespace" # Wait for namespace to be fully deleted sleep 5 -create_test_namespace e2e-ns-4 +create_test_namespace kubemirror-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)" +wait_for_resource secret test-no-labels-1 kubemirror-e2e-ns-4 30 || log_warn "Mirror not auto-created (may require source update)" complete_test_scenario "17" "pass" @@ -345,18 +474,22 @@ complete_test_scenario "17" "pass" # SCENARIO 18: Source deleted #=============================================================================== +if ! should_run_scenario 18; then + continue +fi + 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" +create_test_namespace kubemirror-e2e-ns-5 +create_source secret test-delete-source "$E2E_SOURCE_NS" true true "kubemirror-e2e-ns-5" "delete-test" -verify_mirrors_exist secret test-delete-source e2e-ns-5 +verify_mirrors_exist secret test-delete-source kubemirror-e2e-ns-5 # Delete source -kubectl delete secret test-delete-source -n default +kubectl delete secret test-delete-source -n "$E2E_SOURCE_NS" # Mirror should be cascade deleted -verify_orphan_cleanup secret test-delete-source e2e-ns-5 +verify_orphan_cleanup secret test-delete-source kubemirror-e2e-ns-5 complete_test_scenario "18" "pass" @@ -364,19 +497,23 @@ complete_test_scenario "18" "pass" # SCENARIO 19: Target manually deleted (should be recreated) #=============================================================================== +if ! should_run_scenario 19; then + continue +fi + run_test_scenario "19: Manually delete target mirror (should recreate)" -create_source secret test-recreate default true true "e2e-prod-1" "recreate-data" +create_source secret test-recreate "$E2E_SOURCE_NS" true true "kubemirror-e2e-prod-1" "recreate-data" -verify_mirrors_exist secret test-recreate e2e-prod-1 +verify_mirrors_exist secret test-recreate kubemirror-e2e-prod-1 # Manually delete the mirror -kubectl delete secret test-recreate -n e2e-prod-1 +kubectl delete secret test-recreate -n kubemirror-e2e-prod-1 # Should be automatically recreated -wait_for_resource secret test-recreate e2e-prod-1 15 +wait_for_resource secret test-recreate kubemirror-e2e-prod-1 15 -assert_resource_exists secret test-recreate e2e-prod-1 +assert_resource_exists secret test-recreate kubemirror-e2e-prod-1 complete_test_scenario "19" "pass" @@ -384,24 +521,28 @@ complete_test_scenario "19" "pass" # SCENARIO 20: ConfigMap with same test patterns #=============================================================================== +if ! should_run_scenario 20; then + continue +fi + run_test_scenario "20: ConfigMap with pattern matching" -create_source configmap test-cm-pattern default true true "e2e-app-*" "cm-data-v1" +create_source configmap test-cm-pattern "$E2E_SOURCE_NS" true true "kubemirror-e2e-app-*" "cm-data-v1" -verify_mirrors_exist configmap test-cm-pattern e2e-app-1 e2e-app-2 +verify_mirrors_exist configmap test-cm-pattern kubemirror-e2e-app-1 kubemirror-e2e-app-2 # Update content -update_source_data configmap test-cm-pattern default "cm-data-v2" +update_source_data configmap test-cm-pattern "$E2E_SOURCE_NS" "cm-data-v2" sleep 20 -verify_mirror_data configmap test-cm-pattern default e2e-app-1 "cm-data-v2" +verify_mirror_data configmap test-cm-pattern "$E2E_SOURCE_NS" kubemirror-e2e-app-1 "cm-data-v2" # Change pattern -update_source_annotations configmap test-cm-pattern default true "e2e-db-*" +update_source_annotations configmap test-cm-pattern "$E2E_SOURCE_NS" true "kubemirror-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 +verify_orphan_cleanup configmap test-cm-pattern kubemirror-e2e-app-1 kubemirror-e2e-app-2 +verify_mirrors_exist configmap test-cm-pattern kubemirror-e2e-db-1 kubemirror-e2e-db-2 complete_test_scenario "20" "pass" @@ -409,21 +550,25 @@ complete_test_scenario "20" "pass" # SCENARIO 21: Mix of explicit and pattern #=============================================================================== +if ! should_run_scenario 21; then + continue +fi + run_test_scenario "21: Mix of explicit namespaces and patterns" -create_test_namespace e2e-test-ns +create_test_namespace kubemirror-e2e-test-ns -create_source secret test-mixed default true true "e2e-test-ns,e2e-stage-*" "mixed-data" +create_source secret test-mixed "$E2E_SOURCE_NS" true true "kubemirror-e2e-test-ns,kubemirror-e2e-stage-*" "mixed-data" -create_test_namespace e2e-stage-2 +create_test_namespace kubemirror-e2e-stage-2 -verify_mirrors_exist secret test-mixed e2e-test-ns e2e-stage-1 e2e-stage-2 +verify_mirrors_exist secret test-mixed kubemirror-e2e-test-ns kubemirror-e2e-stage-1 kubemirror-e2e-stage-2 # Remove explicit, keep pattern -update_source_annotations secret test-mixed default true "e2e-stage-*" +update_source_annotations secret test-mixed "$E2E_SOURCE_NS" true "kubemirror-e2e-stage-*" -verify_orphan_cleanup secret test-mixed e2e-test-ns -verify_mirrors_exist secret test-mixed e2e-stage-1 e2e-stage-2 +verify_orphan_cleanup secret test-mixed kubemirror-e2e-test-ns +verify_mirrors_exist secret test-mixed kubemirror-e2e-stage-1 kubemirror-e2e-stage-2 complete_test_scenario "21" "pass" @@ -431,13 +576,17 @@ complete_test_scenario "21" "pass" # SCENARIO 22: Sync annotation removed completely #=============================================================================== +if ! should_run_scenario 22; then + continue +fi + run_test_scenario "22: Remove sync annotation completely" -verify_mirrors_exist secret test-mixed e2e-stage-1 e2e-stage-2 +verify_mirrors_exist secret test-mixed kubemirror-e2e-stage-1 kubemirror-e2e-stage-2 -update_source_annotations secret test-mixed default "" "" +update_source_annotations secret test-mixed "$E2E_SOURCE_NS" "" "" -verify_orphan_cleanup secret test-mixed e2e-stage-1 e2e-stage-2 +verify_orphan_cleanup secret test-mixed kubemirror-e2e-stage-1 kubemirror-e2e-stage-2 complete_test_scenario "22" "pass" @@ -445,6 +594,10 @@ complete_test_scenario "22" "pass" # SCENARIO 23: Traefik Middleware CRD (test generic CRD support) #=============================================================================== +if ! should_run_scenario 23; then + continue +fi + run_test_scenario "23: Traefik Middleware CRD with spec updates" # Create Traefik Middleware CRD manually (CRDs aren't supported by create_source helper) @@ -453,13 +606,13 @@ apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: test-middleware - namespace: default + namespace: $E2E_SOURCE_NS 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" + kubemirror.raczylo.com/target-namespaces: "kubemirror-e2e-app-1,kubemirror-e2e-app-2" spec: basicAuth: secret: auth-secret-v1 @@ -472,15 +625,15 @@ EOF sleep 5 # Verify mirrors created with correct spec -verify_mirrors_exist middleware.traefik.io test-middleware e2e-app-1 e2e-app-2 +verify_mirrors_exist middleware.traefik.io test-middleware kubemirror-e2e-app-1 kubemirror-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 "") +app1_secret=$(kubectl get middleware test-middleware -n kubemirror-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" + log_success "Mirror spec in kubemirror-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" + log_fail "Mirror spec in kubemirror-e2e-app-1 does not match. Expected: auth-secret-v1, Got: $app1_secret" ((FAIL_COUNT++)) fi @@ -490,13 +643,13 @@ apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: test-middleware - namespace: default + namespace: $E2E_SOURCE_NS 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" + kubemirror.raczylo.com/target-namespaces: "kubemirror-e2e-app-1,kubemirror-e2e-app-2" spec: basicAuth: secret: auth-secret-v2-updated @@ -510,41 +663,655 @@ 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 "") +app1_secret_updated=$(kubectl get middleware test-middleware -n kubemirror-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" + log_success "Mirror spec in kubemirror-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" + log_fail "Mirror spec in kubemirror-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 "") +app1_header=$(kubectl get middleware test-middleware -n kubemirror-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" + log_success "Mirror spec headers in kubemirror-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" + log_fail "Mirror spec headers in kubemirror-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 +kubectl annotate middleware test-middleware -n "$E2E_SOURCE_NS" \ + kubemirror.raczylo.com/target-namespaces="kubemirror-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_orphan_cleanup middleware.traefik.io test-middleware kubemirror-e2e-app-1 kubemirror-e2e-app-2 # Verify new mirrors created -verify_mirrors_exist middleware.traefik.io test-middleware e2e-db-1 e2e-db-2 +verify_mirrors_exist middleware.traefik.io test-middleware kubemirror-e2e-db-1 kubemirror-e2e-db-2 # Clean up CRD -kubectl delete middleware test-middleware -n default --ignore-not-found=true >/dev/null 2>&1 +kubectl delete middleware test-middleware -n "$E2E_SOURCE_NS" --ignore-not-found=true >/dev/null 2>&1 complete_test_scenario "23" "pass" +#=============================================================================== +# SCENARIO 24: Transformation - Static value +#=============================================================================== + +if ! should_run_scenario 24; then + continue +fi + +run_test_scenario "24: Transformation - Static value replacement" + +# Create Secret with value transformation +cat </dev/null 2>&1 +apiVersion: v1 +kind: Secret +metadata: + name: test-transform-value + namespace: $E2E_SOURCE_NS + labels: + kubemirror.raczylo.com/enabled: "true" + test-resource: e2e + annotations: + kubemirror.raczylo.com/sync: "true" + kubemirror.raczylo.com/target-namespaces: "kubemirror-e2e-app-1,kubemirror-e2e-app-2" + kubemirror.raczylo.com/transform: | + rules: + - path: data.ENVIRONMENT + value: "production" + - path: data.LOG_LEVEL + value: "ERROR" +type: Opaque +stringData: + ENVIRONMENT: "development" + LOG_LEVEL: "DEBUG" + APP_KEY: "original-key" +EOF + +sleep 10 + +# Verify mirrors created +verify_mirrors_exist secret test-transform-value kubemirror-e2e-app-1 kubemirror-e2e-app-2 + +# Verify transformed values +app1_env=$(kubectl get secret test-transform-value -n kubemirror-e2e-app-1 -o jsonpath='{.data.ENVIRONMENT}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_env" = "production" ]; then + log_success "Transformed value in kubemirror-e2e-app-1 correct: ENVIRONMENT=production" + ((PASS_COUNT++)) +else + log_fail "Transform failed. Expected: production, Got: $app1_env" + ((FAIL_COUNT++)) +fi + +app1_log=$(kubectl get secret test-transform-value -n kubemirror-e2e-app-1 -o jsonpath='{.data.LOG_LEVEL}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_log" = "ERROR" ]; then + log_success "Transformed value in kubemirror-e2e-app-1 correct: LOG_LEVEL=ERROR" + ((PASS_COUNT++)) +else + log_fail "Transform failed. Expected: ERROR, Got: $app1_log" + ((FAIL_COUNT++)) +fi + +# Verify untransformed value preserved +app1_key=$(kubectl get secret test-transform-value -n kubemirror-e2e-app-1 -o jsonpath='{.data.APP_KEY}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_key" = "original-key" ]; then + log_success "Untransformed value preserved: APP_KEY=original-key" + ((PASS_COUNT++)) +else + log_fail "Untransformed value changed. Expected: original-key, Got: $app1_key" + ((FAIL_COUNT++)) +fi + +kubectl delete secret test-transform-value -n "$E2E_SOURCE_NS" --ignore-not-found=true >/dev/null 2>&1 +complete_test_scenario "24" "pass" + +#=============================================================================== +# SCENARIO 25: Transformation - Template with context variables +#=============================================================================== + +if ! should_run_scenario 25; then + continue +fi + +run_test_scenario "25: Transformation - Template with context variables" + +# Create Secret with template transformation +cat </dev/null 2>&1 +apiVersion: v1 +kind: Secret +metadata: + name: test-transform-template + namespace: $E2E_SOURCE_NS + labels: + kubemirror.raczylo.com/enabled: "true" + test-resource: e2e + annotations: + kubemirror.raczylo.com/sync: "true" + kubemirror.raczylo.com/target-namespaces: "kubemirror-e2e-app-1,kubemirror-e2e-db-1" + kubemirror.raczylo.com/transform: | + rules: + - path: data.DB_HOST + template: "{{.TargetNamespace}}.postgres.svc.cluster.local" + - path: data.DB_NAME + template: "app_{{replace .TargetNamespace \"-\" \"_\"}}" + - path: data.CACHE_KEY + template: "{{upper .TargetNamespace}}:cache" +type: Opaque +stringData: + DB_HOST: "localhost" + DB_NAME: "dev" + CACHE_KEY: "dev:cache" +EOF + +sleep 10 + +# Verify mirrors created +verify_mirrors_exist secret test-transform-template kubemirror-e2e-app-1 kubemirror-e2e-db-1 + +# Verify template transformations in kubemirror-e2e-app-1 +app1_host=$(kubectl get secret test-transform-template -n kubemirror-e2e-app-1 -o jsonpath='{.data.DB_HOST}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_host" = "kubemirror-e2e-app-1.postgres.svc.cluster.local" ]; then + log_success "Template transformation correct: DB_HOST=kubemirror-e2e-app-1.postgres.svc.cluster.local" + ((PASS_COUNT++)) +else + log_fail "Template failed. Expected: kubemirror-e2e-app-1.postgres.svc.cluster.local, Got: $app1_host" + ((FAIL_COUNT++)) +fi + +app1_dbname=$(kubectl get secret test-transform-template -n kubemirror-e2e-app-1 -o jsonpath='{.data.DB_NAME}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_dbname" = "app_kubemirror_e2e_app_1" ]; then + log_success "Template with replace function correct: DB_NAME=app_e2e_app_1" + ((PASS_COUNT++)) +else + log_fail "Template with replace failed. Expected: app_e2e_app_1, Got: $app1_dbname" + ((FAIL_COUNT++)) +fi + +app1_cache=$(kubectl get secret test-transform-template -n kubemirror-e2e-app-1 -o jsonpath='{.data.CACHE_KEY}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_cache" = "KUBEMIRROR-E2E-APP-1:cache" ]; then + log_success "Template with upper function correct: CACHE_KEY=E2E-APP-1:cache" + ((PASS_COUNT++)) +else + log_fail "Template with upper failed. Expected: E2E-APP-1:cache, Got: $app1_cache" + ((FAIL_COUNT++)) +fi + +# Verify template transformations in kubemirror-e2e-db-1 (different namespace = different values) +db1_host=$(kubectl get secret test-transform-template -n kubemirror-e2e-db-1 -o jsonpath='{.data.DB_HOST}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$db1_host" = "kubemirror-e2e-db-1.postgres.svc.cluster.local" ]; then + log_success "Template namespace-specific: DB_HOST=kubemirror-e2e-db-1.postgres.svc.cluster.local" + ((PASS_COUNT++)) +else + log_fail "Template namespace failed. Expected: kubemirror-e2e-db-1.postgres.svc.cluster.local, Got: $db1_host" + ((FAIL_COUNT++)) +fi + +kubectl delete secret test-transform-template -n "$E2E_SOURCE_NS" --ignore-not-found=true >/dev/null 2>&1 +complete_test_scenario "25" "pass" + +#=============================================================================== +# SCENARIO 26: Transformation - Merge maps (labels/annotations) +#=============================================================================== + +if ! should_run_scenario 26; then + continue +fi + +run_test_scenario "26: Transformation - Merge maps" + +# Create ConfigMap with merge transformation +cat </dev/null 2>&1 +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-transform-merge + namespace: $E2E_SOURCE_NS + labels: + kubemirror.raczylo.com/enabled: "true" + test-resource: e2e + app: "test-app" + annotations: + kubemirror.raczylo.com/sync: "true" + kubemirror.raczylo.com/target-namespaces: "kubemirror-e2e-app-1,kubemirror-e2e-app-2" + kubemirror.raczylo.com/transform: | + rules: + - path: metadata.labels + merge: + environment: "production" + managed-by: "kubemirror" + tier: "backend" +data: + config: "value" +EOF + +sleep 10 + +# Verify mirrors created +verify_mirrors_exist configmap test-transform-merge kubemirror-e2e-app-1 kubemirror-e2e-app-2 + +# Verify merged labels +app1_env_label=$(kubectl get configmap test-transform-merge -n kubemirror-e2e-app-1 -o jsonpath='{.metadata.labels.environment}' 2>/dev/null || echo "") +if [ "$app1_env_label" = "production" ]; then + log_success "Merged label added: environment=production" + ((PASS_COUNT++)) +else + log_fail "Merge failed. Expected: production, Got: $app1_env_label" + ((FAIL_COUNT++)) +fi + +app1_tier_label=$(kubectl get configmap test-transform-merge -n kubemirror-e2e-app-1 -o jsonpath='{.metadata.labels.tier}' 2>/dev/null || echo "") +if [ "$app1_tier_label" = "backend" ]; then + log_success "Merged label added: tier=backend" + ((PASS_COUNT++)) +else + log_fail "Merge failed. Expected: backend, Got: $app1_tier_label" + ((FAIL_COUNT++)) +fi + +# Verify original labels preserved +app1_app_label=$(kubectl get configmap test-transform-merge -n kubemirror-e2e-app-1 -o jsonpath='{.metadata.labels.app}' 2>/dev/null || echo "") +if [ "$app1_app_label" = "test-app" ]; then + log_success "Original label preserved: app=test-app" + ((PASS_COUNT++)) +else + log_fail "Original label lost. Expected: test-app, Got: $app1_app_label" + ((FAIL_COUNT++)) +fi + +kubectl delete configmap test-transform-merge -n "$E2E_SOURCE_NS" --ignore-not-found=true >/dev/null 2>&1 +complete_test_scenario "26" "pass" + +#=============================================================================== +# SCENARIO 27: Transformation - Delete fields +#=============================================================================== + +if ! should_run_scenario 27; then + continue +fi + +run_test_scenario "27: Transformation - Delete sensitive fields" + +# Create Secret with delete transformation +cat </dev/null 2>&1 +apiVersion: v1 +kind: Secret +metadata: + name: test-transform-delete + namespace: $E2E_SOURCE_NS + labels: + kubemirror.raczylo.com/enabled: "true" + test-resource: e2e + annotations: + kubemirror.raczylo.com/sync: "true" + kubemirror.raczylo.com/target-namespaces: "kubemirror-e2e-app-1,kubemirror-e2e-app-2" + kubemirror.raczylo.com/transform: | + rules: + - path: data.ADMIN_PASSWORD + delete: true + - path: data.ROOT_TOKEN + delete: true +type: Opaque +stringData: + APP_KEY: "app-key-12345" + ADMIN_PASSWORD: "super-secret" + ROOT_TOKEN: "root-token-xyz" +EOF + +sleep 10 + +# Verify mirrors created +verify_mirrors_exist secret test-transform-delete kubemirror-e2e-app-1 kubemirror-e2e-app-2 + +# Verify sensitive fields deleted +app1_admin=$(kubectl get secret test-transform-delete -n kubemirror-e2e-app-1 -o jsonpath='{.data.ADMIN_PASSWORD}' 2>/dev/null || echo "") +if [ -z "$app1_admin" ]; then + log_success "Sensitive field deleted: ADMIN_PASSWORD removed" + ((PASS_COUNT++)) +else + log_fail "Delete failed. ADMIN_PASSWORD still exists: $app1_admin" + ((FAIL_COUNT++)) +fi + +app1_token=$(kubectl get secret test-transform-delete -n kubemirror-e2e-app-1 -o jsonpath='{.data.ROOT_TOKEN}' 2>/dev/null || echo "") +if [ -z "$app1_token" ]; then + log_success "Sensitive field deleted: ROOT_TOKEN removed" + ((PASS_COUNT++)) +else + log_fail "Delete failed. ROOT_TOKEN still exists: $app1_token" + ((FAIL_COUNT++)) +fi + +# Verify non-deleted field preserved +app1_key=$(kubectl get secret test-transform-delete -n kubemirror-e2e-app-1 -o jsonpath='{.data.APP_KEY}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_key" = "app-key-12345" ]; then + log_success "Non-deleted field preserved: APP_KEY=app-key-12345" + ((PASS_COUNT++)) +else + log_fail "Field incorrectly deleted. Expected: app-key-12345, Got: $app1_key" + ((FAIL_COUNT++)) +fi + +kubectl delete secret test-transform-delete -n "$E2E_SOURCE_NS" --ignore-not-found=true >/dev/null 2>&1 +complete_test_scenario "27" "pass" + +#=============================================================================== +# SCENARIO 28: Transformation - Namespace pattern-specific rules +#=============================================================================== + +if ! should_run_scenario 28; then + continue +fi + +run_test_scenario "28: Transformation - Namespace pattern-specific transformations" + +# Create ConfigMap with namespace-pattern-specific transformations +cat </dev/null 2>&1 +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-transform-pattern + namespace: $E2E_SOURCE_NS + labels: + kubemirror.raczylo.com/enabled: "true" + test-resource: e2e + annotations: + kubemirror.raczylo.com/sync: "true" + kubemirror.raczylo.com/target-namespaces: "kubemirror-e2e-app-1,kubemirror-e2e-db-1,kubemirror-e2e-prod-1" + kubemirror.raczylo.com/transform: | + rules: + # Apply to all namespaces + - path: data.GLOBAL_CONFIG + value: "enabled" + + # Apply only to kubemirror-e2e-app-* namespaces + - path: data.APP_MODE + value: "application" + namespacePattern: "kubemirror-e2e-app-*" + + # Apply only to kubemirror-e2e-db-* namespaces + - path: data.DB_MODE + value: "database" + namespacePattern: "kubemirror-e2e-db-*" + + # Apply only to kubemirror-e2e-prod-* namespaces + - path: data.SECURITY_LEVEL + value: "high" + namespacePattern: "kubemirror-e2e-prod-*" +data: + GLOBAL_CONFIG: "disabled" + APP_MODE: "none" + DB_MODE: "none" + SECURITY_LEVEL: "low" +EOF + +sleep 10 + +# Verify mirrors created +verify_mirrors_exist configmap test-transform-pattern kubemirror-e2e-app-1 kubemirror-e2e-db-1 kubemirror-e2e-prod-1 + +# Verify global transformation applied to all +app1_global=$(kubectl get configmap test-transform-pattern -n kubemirror-e2e-app-1 -o jsonpath='{.data.GLOBAL_CONFIG}' 2>/dev/null || echo "") +if [ "$app1_global" = "enabled" ]; then + log_success "Global transformation applied to kubemirror-e2e-app-1: GLOBAL_CONFIG=enabled" + ((PASS_COUNT++)) +else + log_fail "Global transform failed. Expected: enabled, Got: $app1_global" + ((FAIL_COUNT++)) +fi + +# Verify pattern-specific transformation in kubemirror-e2e-app-1 +app1_mode=$(kubectl get configmap test-transform-pattern -n kubemirror-e2e-app-1 -o jsonpath='{.data.APP_MODE}' 2>/dev/null || echo "") +if [ "$app1_mode" = "application" ]; then + log_success "Pattern-specific transform for kubemirror-e2e-app-*: APP_MODE=application" + ((PASS_COUNT++)) +else + log_fail "Pattern transform failed. Expected: application, Got: $app1_mode" + ((FAIL_COUNT++)) +fi + +# Verify pattern-specific transformation in kubemirror-e2e-db-1 +db1_mode=$(kubectl get configmap test-transform-pattern -n kubemirror-e2e-db-1 -o jsonpath='{.data.DB_MODE}' 2>/dev/null || echo "") +if [ "$db1_mode" = "database" ]; then + log_success "Pattern-specific transform for kubemirror-e2e-db-*: DB_MODE=database" + ((PASS_COUNT++)) +else + log_fail "Pattern transform failed. Expected: database, Got: $db1_mode" + ((FAIL_COUNT++)) +fi + +# Verify pattern-specific transformation in kubemirror-e2e-prod-1 +prod1_security=$(kubectl get configmap test-transform-pattern -n kubemirror-e2e-prod-1 -o jsonpath='{.data.SECURITY_LEVEL}' 2>/dev/null || echo "") +if [ "$prod1_security" = "high" ]; then + log_success "Pattern-specific transform for kubemirror-e2e-prod-*: SECURITY_LEVEL=high" + ((PASS_COUNT++)) +else + log_fail "Pattern transform failed. Expected: high, Got: $prod1_security" + ((FAIL_COUNT++)) +fi + +# Verify pattern-specific transformation NOT applied to wrong namespace +app1_db_mode=$(kubectl get configmap test-transform-pattern -n kubemirror-e2e-app-1 -o jsonpath='{.data.DB_MODE}' 2>/dev/null || echo "") +if [ "$app1_db_mode" = "none" ]; then + log_success "Pattern-specific transform correctly excluded from kubemirror-e2e-app-1: DB_MODE=none" + ((PASS_COUNT++)) +else + log_fail "Pattern incorrectly applied. Expected: none, Got: $app1_db_mode" + ((FAIL_COUNT++)) +fi + +kubectl delete configmap test-transform-pattern -n "$E2E_SOURCE_NS" --ignore-not-found=true >/dev/null 2>&1 +complete_test_scenario "28" "pass" + +#=============================================================================== +# SCENARIO 29: Transformation - Multiple rule types combined +#=============================================================================== + +if ! should_run_scenario 29; then + continue +fi + +run_test_scenario "29: Transformation - Multiple rule types combined" + +# Create Secret with multiple transformation types +cat </dev/null 2>&1 +apiVersion: v1 +kind: Secret +metadata: + name: test-transform-multi + namespace: $E2E_SOURCE_NS + labels: + kubemirror.raczylo.com/enabled: "true" + test-resource: e2e + original-label: "keep-me" + annotations: + kubemirror.raczylo.com/sync: "true" + kubemirror.raczylo.com/target-namespaces: "kubemirror-e2e-app-1,kubemirror-e2e-app-2" + kubemirror.raczylo.com/transform: | + rules: + # Static value + - path: data.ENCRYPTION + value: "AES-256" + + # Template + - path: data.SERVICE_URL + template: "https://{{.TargetNamespace}}.example.com" + + # Delete sensitive field + - path: data.DEV_SECRET + delete: true + + # Merge labels + - path: metadata.labels + merge: + environment: "production" + security: "enabled" +type: Opaque +stringData: + ENCRYPTION: "AES-128" + SERVICE_URL: "http://localhost" + APP_KEY: "key-123" + DEV_SECRET: "dev-only" +EOF + +sleep 10 + +# Verify mirrors created +verify_mirrors_exist secret test-transform-multi kubemirror-e2e-app-1 kubemirror-e2e-app-2 + +# Verify value transformation +app1_enc=$(kubectl get secret test-transform-multi -n kubemirror-e2e-app-1 -o jsonpath='{.data.ENCRYPTION}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_enc" = "AES-256" ]; then + log_success "Value transform in multi-rule: ENCRYPTION=AES-256" + ((PASS_COUNT++)) +else + log_fail "Value transform failed. Expected: AES-256, Got: $app1_enc" + ((FAIL_COUNT++)) +fi + +# Verify template transformation +app1_url=$(kubectl get secret test-transform-multi -n kubemirror-e2e-app-1 -o jsonpath='{.data.SERVICE_URL}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_url" = "https://kubemirror-e2e-app-1.example.com" ]; then + log_success "Template transform in multi-rule: SERVICE_URL=https://kubemirror-e2e-app-1.example.com" + ((PASS_COUNT++)) +else + log_fail "Template transform failed. Expected: https://kubemirror-e2e-app-1.example.com, Got: $app1_url" + ((FAIL_COUNT++)) +fi + +# Verify delete transformation +app1_dev=$(kubectl get secret test-transform-multi -n kubemirror-e2e-app-1 -o jsonpath='{.data.DEV_SECRET}' 2>/dev/null || echo "") +if [ -z "$app1_dev" ]; then + log_success "Delete transform in multi-rule: DEV_SECRET removed" + ((PASS_COUNT++)) +else + log_fail "Delete transform failed. DEV_SECRET still exists" + ((FAIL_COUNT++)) +fi + +# Verify merge transformation +app1_env_label=$(kubectl get secret test-transform-multi -n kubemirror-e2e-app-1 -o jsonpath='{.metadata.labels.environment}' 2>/dev/null || echo "") +if [ "$app1_env_label" = "production" ]; then + log_success "Merge transform in multi-rule: environment=production" + ((PASS_COUNT++)) +else + log_fail "Merge transform failed. Expected: production, Got: $app1_env_label" + ((FAIL_COUNT++)) +fi + +# Verify original label preserved after merge +app1_orig_label=$(kubectl get secret test-transform-multi -n kubemirror-e2e-app-1 -o jsonpath='{.metadata.labels.original-label}' 2>/dev/null || echo "") +if [ "$app1_orig_label" = "keep-me" ]; then + log_success "Original label preserved after merge: original-label=keep-me" + ((PASS_COUNT++)) +else + log_fail "Original label lost. Expected: keep-me, Got: $app1_orig_label" + ((FAIL_COUNT++)) +fi + +kubectl delete secret test-transform-multi -n "$E2E_SOURCE_NS" --ignore-not-found=true >/dev/null 2>&1 +complete_test_scenario "29" "pass" + +#=============================================================================== +# SCENARIO 30: Transformation - Update transform rules (reconciliation) +#=============================================================================== + +if ! should_run_scenario 30; then + continue +fi + +run_test_scenario "30: Transformation - Update transform rules" + +# Create Secret with initial transformation +cat </dev/null 2>&1 +apiVersion: v1 +kind: Secret +metadata: + name: test-transform-update + namespace: $E2E_SOURCE_NS + labels: + kubemirror.raczylo.com/enabled: "true" + test-resource: e2e + annotations: + kubemirror.raczylo.com/sync: "true" + kubemirror.raczylo.com/target-namespaces: "kubemirror-e2e-app-1" + kubemirror.raczylo.com/transform: | + rules: + - path: data.VERSION + value: "v1" +type: Opaque +stringData: + VERSION: "v0" + DATA: "original" +EOF + +sleep 10 + +# Verify initial transformation +app1_v1=$(kubectl get secret test-transform-update -n kubemirror-e2e-app-1 -o jsonpath='{.data.VERSION}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_v1" = "v1" ]; then + log_success "Initial transformation applied: VERSION=v1" + ((PASS_COUNT++)) +else + log_fail "Initial transform failed. Expected: v1, Got: $app1_v1" + ((FAIL_COUNT++)) +fi + +# Update transformation rules +cat </dev/null 2>&1 +apiVersion: v1 +kind: Secret +metadata: + name: test-transform-update + namespace: $E2E_SOURCE_NS + labels: + kubemirror.raczylo.com/enabled: "true" + test-resource: e2e + annotations: + kubemirror.raczylo.com/sync: "true" + kubemirror.raczylo.com/target-namespaces: "kubemirror-e2e-app-1" + kubemirror.raczylo.com/transform: | + rules: + - path: data.VERSION + value: "v2-updated" + - path: data.NEW_FIELD + value: "added-by-transform" +type: Opaque +stringData: + VERSION: "v0" + DATA: "original" +EOF + +sleep 15 + +# Verify updated transformation +app1_v2=$(kubectl get secret test-transform-update -n kubemirror-e2e-app-1 -o jsonpath='{.data.VERSION}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_v2" = "v2-updated" ]; then + log_success "Updated transformation applied: VERSION=v2-updated" + ((PASS_COUNT++)) +else + log_fail "Updated transform failed. Expected: v2-updated, Got: $app1_v2" + ((FAIL_COUNT++)) +fi + +# Verify new transformation rule applied +app1_new=$(kubectl get secret test-transform-update -n kubemirror-e2e-app-1 -o jsonpath='{.data.NEW_FIELD}' 2>/dev/null | base64 -d 2>/dev/null || echo "") +if [ "$app1_new" = "added-by-transform" ]; then + log_success "New transformation rule applied: NEW_FIELD=added-by-transform" + ((PASS_COUNT++)) +else + log_fail "New rule failed. Expected: added-by-transform, Got: $app1_new" + ((FAIL_COUNT++)) +fi + +kubectl delete secret test-transform-update -n "$E2E_SOURCE_NS" --ignore-not-found=true >/dev/null 2>&1 +complete_test_scenario "30" "pass" + #=============================================================================== # Final Summary #=============================================================================== diff --git a/e2e/test-parallel.sh b/e2e/test-parallel.sh new file mode 100755 index 0000000..0cc93d7 --- /dev/null +++ b/e2e/test-parallel.sh @@ -0,0 +1,207 @@ +#!/bin/bash + +# Parallel E2E Test Runner for KubeMirror +# Runs independent tests in parallel batches for faster execution +# +# Usage: +# ./test-parallel.sh # Run all tests in parallel batches +# +# Performance: +# - Sequential execution: ~5-7 minutes for all 30 scenarios +# - Parallel execution: ~3-4 minutes (batched by independence) +# +# Batching Strategy: +# - Sequential: Scenarios 1-11 (core lifecycle - must run sequentially) +# - Parallel Batch 1: Scenarios 12-15 (namespace labels) +# - Parallel Batch 2: Scenarios 16-19 (deletion scenarios) +# - Parallel Batch 3: Scenarios 20-23 (mixed resources) +# - Parallel Batch 4: Scenarios 24-27 (transformations part 1) +# - Parallel Batch 5: Scenarios 28-30 (transformations part 2) +# +# Individual scenario logs: e2e/.test-scenario-{num}.log + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +TEST_NAME="Parallel E2E Tests" + +# Colors +CYAN='\033[0;36m' +NC='\033[0m' + +# Batch execution tracking +BATCH_RESULTS=() +TOTAL_START_TIME=$(date +%s) + +# Run a single scenario by number +run_scenario() { + local scenario_num=$1 + local log_file="$SCRIPT_DIR/.test-scenario-${scenario_num}.log" + + echo -e "${CYAN}[BATCH]${NC} Starting scenario $scenario_num in background" + + # Run the specific scenario using the comprehensive test script + # Pass scenario number as argument to run only that scenario + bash "$SCRIPT_DIR/test-comprehensive.sh" "$scenario_num" > "$log_file" 2>&1 + local exit_code=$? + + # Store result + echo "$scenario_num:$exit_code" >> "$SCRIPT_DIR/.batch-results.tmp" + + if [ $exit_code -eq 0 ]; then + echo -e "${GREEN}[PASS]${NC} Scenario $scenario_num completed successfully" + else + echo -e "${RED}[FAIL]${NC} Scenario $scenario_num failed (exit code: $exit_code)" + fi + + return $exit_code +} + +# Run multiple scenarios in parallel +run_parallel_batch() { + local batch_name=$1 + shift + local scenarios=("$@") + + echo "" + echo -e "${CYAN}======================================${NC}" + echo -e "${CYAN}Batch: $batch_name${NC}" + echo -e "${CYAN}Scenarios: ${scenarios[*]}${NC}" + echo -e "${CYAN}======================================${NC}" + + local batch_start=$(date +%s) + + # Clear batch results file + > "$SCRIPT_DIR/.batch-results.tmp" + + # Start all scenarios in background + local pids=() + for scenario in "${scenarios[@]}"; do + run_scenario "$scenario" & + pids+=($!) + done + + # Wait for all to complete + local batch_failed=0 + for pid in "${pids[@]}"; do + wait $pid || batch_failed=1 + done + + local batch_end=$(date +%s) + local batch_duration=$((batch_end - batch_start)) + + echo -e "${CYAN}Batch '$batch_name' completed in ${batch_duration}s${NC}" + + return $batch_failed +} + +# Main execution +main() { + log_info "Starting $TEST_NAME" + log_info "This runner executes independent tests in parallel for faster results" + + # Ensure controller is running + if ! pgrep -f "kubemirror" > /dev/null; then + log_fail "KubeMirror controller is not running!" + log_info "Please start the controller first: ./e2e/run-all-tests.sh" + exit 1 + fi + + # Clean up any previous test artifacts + log_info "Cleaning up previous test artifacts" + rm -f "$SCRIPT_DIR/.test-scenario-"*.log + rm -f "$SCRIPT_DIR/.batch-results.tmp" + + local overall_failed=0 + + # ======================================================================== + # SEQUENTIAL BATCH: Core Lifecycle (Scenarios 1-11) + # These tests modify the same resource and MUST run sequentially + # ======================================================================== + echo "" + echo -e "${CYAN}======================================${NC}" + echo -e "${CYAN}Sequential Batch: Core Lifecycle${NC}" + echo -e "${CYAN}Scenarios: 1-11 (sequential execution required)${NC}" + echo -e "${CYAN}======================================${NC}" + + local seq_start=$(date +%s) + bash "$SCRIPT_DIR/test-comprehensive.sh" 1 2 3 4 5 6 7 8 9 10 11 + if [ $? -ne 0 ]; then + overall_failed=1 + log_fail "Sequential batch (1-11) failed" + fi + local seq_end=$(date +%s) + echo -e "${CYAN}Sequential batch completed in $((seq_end - seq_start))s${NC}" + + # ======================================================================== + # PARALLEL BATCH 1: Namespace Label Tests (Scenarios 12-15) + # ======================================================================== + run_parallel_batch "Namespace Labels" 12 13 14 15 + [ $? -ne 0 ] && overall_failed=1 + + # ======================================================================== + # PARALLEL BATCH 2: Deletion Scenarios (Scenarios 16-19) + # ======================================================================== + run_parallel_batch "Deletion Scenarios" 16 17 18 19 + [ $? -ne 0 ] && overall_failed=1 + + # ======================================================================== + # PARALLEL BATCH 3: Mixed Resources (Scenarios 20-23) + # ======================================================================== + run_parallel_batch "Mixed Resources" 20 21 22 23 + [ $? -ne 0 ] && overall_failed=1 + + # ======================================================================== + # PARALLEL BATCH 4: Transformations Part 1 (Scenarios 24-27) + # ======================================================================== + run_parallel_batch "Transformations 1" 24 25 26 27 + [ $? -ne 0 ] && overall_failed=1 + + # ======================================================================== + # PARALLEL BATCH 5: Transformations Part 2 (Scenarios 28-30) + # ======================================================================== + run_parallel_batch "Transformations 2" 28 29 30 + [ $? -ne 0 ] && overall_failed=1 + + # ======================================================================== + # SUMMARY + # ======================================================================== + local total_end=$(date +%s) + local total_duration=$((total_end - TOTAL_START_TIME)) + + echo "" + echo -e "${CYAN}======================================${NC}" + echo -e "${CYAN}Test Execution Summary${NC}" + echo -e "${CYAN}======================================${NC}" + echo -e "Total execution time: ${total_duration}s" + echo -e "Sequential scenarios: 1-11" + echo -e "Parallel batches: 5 batches (scenarios 12-30)" + + if [ $overall_failed -eq 0 ]; then + echo -e "${GREEN}All test batches PASSED!${NC}" + + # Show individual scenario results from logs + echo "" + echo "Individual scenario results:" + for i in {1..30}; do + if [ -f "$SCRIPT_DIR/.test-scenario-${i}.log" ]; then + if grep -q "PASS.*Scenario.*${i}" "$SCRIPT_DIR/.test-scenario-${i}.log" 2>/dev/null; then + echo -e " Scenario $i: ${GREEN}PASS${NC}" + else + echo -e " Scenario $i: ${RED}FAIL${NC}" + fi + fi + done + else + echo -e "${RED}Some test batches FAILED!${NC}" + echo "" + echo "Check individual scenario logs in: $SCRIPT_DIR/.test-scenario-*.log" + fi + + # Cleanup temp files + rm -f "$SCRIPT_DIR/.batch-results.tmp" + + exit $overall_failed +} + +main "$@" diff --git a/pkg/controller/mirror.go b/pkg/controller/mirror.go index fde2601..9b74189 100644 --- a/pkg/controller/mirror.go +++ b/pkg/controller/mirror.go @@ -393,18 +393,61 @@ func GetSourceReference(mirror metav1.Object) (namespace, name, uid string, foun // applyTransformations applies transformation rules from the source to the mirror. // Returns the transformed mirror, or the original mirror if no rules are present. func applyTransformations(source, mirror runtime.Object, targetNamespace string) (runtime.Object, error) { + // Get source annotations to check for transform rules + sourceObj, ok := source.(metav1.Object) + if !ok { + return mirror, nil + } + + sourceAnnotations := sourceObj.GetAnnotations() + if sourceAnnotations == nil { + return mirror, nil + } + + transformRules, hasTransform := sourceAnnotations[transformer.AnnotationTransform] + if !hasTransform || transformRules == "" { + return mirror, nil // No transformation rules + } + + // Temporarily copy transform annotations to mirror for Transform to read + // The Transform function reads rules from the object being transformed + mirrorObj, ok := mirror.(metav1.Object) + if !ok { + return mirror, nil + } + + mirrorAnnotations := mirrorObj.GetAnnotations() + if mirrorAnnotations == nil { + mirrorAnnotations = make(map[string]string) + } + + // Copy transform annotations from source + mirrorAnnotations[transformer.AnnotationTransform] = transformRules + if strictMode, hasStrict := sourceAnnotations[transformer.AnnotationTransformStrict]; hasStrict { + mirrorAnnotations[transformer.AnnotationTransformStrict] = strictMode + } + mirrorObj.SetAnnotations(mirrorAnnotations) + // Build transformation context ctx := buildTransformContext(source, mirror, targetNamespace) // Create transformer with default options t := transformer.NewDefaultTransformer() - // Apply transformations (transformer handles case of no rules gracefully) + // Apply transformations (transformer reads rules from mirror's annotations now) transformed, err := t.Transform(mirror, ctx) if err != nil { return nil, err } + // Remove transform annotations from result (they shouldn't persist on mirrors) + if transformedObj, ok := transformed.(metav1.Object); ok { + annotations := transformedObj.GetAnnotations() + delete(annotations, transformer.AnnotationTransform) + delete(annotations, transformer.AnnotationTransformStrict) + transformedObj.SetAnnotations(annotations) + } + return transformed, nil } diff --git a/pkg/controller/mirror_reconciler.go b/pkg/controller/mirror_reconciler.go new file mode 100644 index 0000000..ed0e75b --- /dev/null +++ b/pkg/controller/mirror_reconciler.go @@ -0,0 +1,152 @@ +package controller + +import ( + "context" + + "github.com/lukaszraczylo/kubemirror/pkg/constants" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// MirrorReconciler reconciles mirrored resources to detect and clean up orphans. +// This reconciler watches resources with the managed-by label and verifies their source still exists. +type MirrorReconciler struct { + client.Client + Scheme *runtime.Scheme + GVK schema.GroupVersionKind // The resource type this reconciler handles +} + +// Reconcile checks if a mirrored resource's source still exists, and deletes the mirror if orphaned. +func (r *MirrorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Fetch the mirror resource + mirror := &unstructured.Unstructured{} + gv := schema.GroupVersion{Group: r.GVK.Group, Version: r.GVK.Version} + mirror.SetGroupVersionKind(gv.WithKind(r.GVK.Kind)) + + if err := r.Get(ctx, req.NamespacedName, mirror); err != nil { + // Mirror already deleted or doesn't exist - nothing to do + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Extract annotations using unstructured helper methods + annotations := mirror.GetAnnotations() + if annotations == nil { + // No annotations - not a valid mirror, skip + return ctrl.Result{}, nil + } + + // Extract source reference from annotations + sourceNs, hasSourceNs := annotations[constants.AnnotationSourceNamespace] + sourceName, hasSourceName := annotations[constants.AnnotationSourceName] + sourceUID, hasSourceUID := annotations[constants.AnnotationSourceUID] + + if !hasSourceNs || !hasSourceName || !hasSourceUID { + // Missing source reference annotations - not a valid mirror or corrupted + logger.V(1).Info("mirror missing source reference annotations, skipping", + "namespace", req.Namespace, "name", req.Name) + return ctrl.Result{}, nil + } + + // Try to fetch the source resource + source := &unstructured.Unstructured{} + source.SetGroupVersionKind(gv.WithKind(r.GVK.Kind)) + sourceKey := types.NamespacedName{ + Namespace: sourceNs, + Name: sourceName, + } + + err := r.Get(ctx, sourceKey, source) + if err != nil { + if client.IgnoreNotFound(err) == nil { + // Source not found - this is an orphaned mirror, delete it + logger.Info("orphaned mirror detected (source deleted), cleaning up", + "mirror", req.NamespacedName, + "sourceNamespace", sourceNs, + "sourceName", sourceName, + "sourceUID", sourceUID) + + if err := r.Delete(ctx, mirror); err != nil { + logger.Error(err, "failed to delete orphaned mirror") + return ctrl.Result{}, err + } + + logger.Info("orphaned mirror deleted successfully", + "mirror", req.NamespacedName, + "sourceNamespace", sourceNs, + "sourceName", sourceName) + return ctrl.Result{}, nil + } + + // Some other error fetching source + logger.Error(err, "failed to fetch source resource for mirror", + "sourceNamespace", sourceNs, "sourceName", sourceName) + return ctrl.Result{}, err + } + + // Source exists - verify UID matches + actualUID := string(source.GetUID()) + if actualUID != sourceUID { + // Source was recreated with different UID - this is a stale mirror + logger.Info("stale mirror detected (source recreated with different UID), cleaning up", + "mirror", req.NamespacedName, + "sourceNamespace", sourceNs, + "sourceName", sourceName, + "expectedUID", sourceUID, + "actualUID", actualUID) + + if err := r.Delete(ctx, mirror); err != nil { + logger.Error(err, "failed to delete stale mirror") + return ctrl.Result{}, err + } + + logger.Info("stale mirror deleted successfully", + "mirror", req.NamespacedName, + "sourceNamespace", sourceNs, + "sourceName", sourceName) + return ctrl.Result{}, nil + } + + // Source exists and UID matches - mirror is valid + logger.V(1).Info("mirror source verified", + "mirror", req.NamespacedName, + "sourceNamespace", sourceNs, + "sourceName", sourceName) + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *MirrorReconciler) SetupWithManager(mgr ctrl.Manager, gvk schema.GroupVersionKind) error { + // Create a predicate that only watches resources with the managed-by label + managedByPredicate := predicate.NewPredicateFuncs(func(obj client.Object) bool { + labels := obj.GetLabels() + if labels == nil { + return false + } + managedBy, exists := labels[constants.LabelManagedBy] + return exists && managedBy == "kubemirror" + }) + + // Convert GVK to resource object for watching + obj := &unstructured.Unstructured{} + gv := schema.GroupVersion{Group: gvk.Group, Version: gvk.Version} + obj.SetGroupVersionKind(gv.WithKind(gvk.Kind)) + + // Set custom controller name to avoid conflicts with source reconciler and multiple API versions + // Include group and version to make it truly unique + controllerName := gvk.Kind + "." + gvk.Version + "." + gvk.Group + "-mirror" + + return ctrl.NewControllerManagedBy(mgr). + For(obj). + Named(controllerName). + WithEventFilter(managedByPredicate). + Complete(r) +} diff --git a/pkg/controller/source_reconciler.go b/pkg/controller/source_reconciler.go index 0e1a421..54ddc72 100644 --- a/pkg/controller/source_reconciler.go +++ b/pkg/controller/source_reconciler.go @@ -15,7 +15,6 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" @@ -138,25 +137,50 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr } // Check if resource is enabled for mirroring - if !isEnabledForMirroring(sourceObj) { - // Silently skip - don't log as it would be too noisy - return r.handleDisabled(ctx, sourceObj) + // Check if resource is being deleted + if !sourceObj.GetDeletionTimestamp().IsZero() { + // Resource is being deleted - clean up mirrors and remove finalizer + if containsString(sourceObj.GetFinalizers(), constants.FinalizerName) { + logger.Info("source being deleted, cleaning up all mirrors") + if err := r.deleteAllMirrors(ctx, sourceObj); err != nil { + logger.Error(err, "failed to delete all mirrors during source deletion") + return ctrl.Result{}, err + } + + // Remove finalizer to allow resource deletion + logger.Info("removing finalizer from source resource") + finalizers := removeString(sourceObj.GetFinalizers(), constants.FinalizerName) + sourceObj.SetFinalizers(finalizers) + if err := r.Update(ctx, source); err != nil { + logger.Error(err, "failed to remove finalizer") + return ctrl.Result{}, err + } + logger.Info("finalizer removed, resource can now be deleted") + } + return ctrl.Result{}, nil } - // Handle deletion - if !sourceObj.GetDeletionTimestamp().IsZero() { - return r.handleDeletion(ctx, source, sourceObj) + if !isEnabledForMirroring(sourceObj) { + // Resource is disabled - remove finalizer if present and delete all mirrors + if containsString(sourceObj.GetFinalizers(), constants.FinalizerName) { + return r.handleDisabled(ctx, sourceObj) + } + // No finalizer, just skip + return ctrl.Result{}, nil } // Add finalizer if not present - // source (*unstructured.Unstructured) already implements client.Object - if !controllerutil.ContainsFinalizer(source, constants.FinalizerName) { - controllerutil.AddFinalizer(source, constants.FinalizerName) + if !containsString(sourceObj.GetFinalizers(), constants.FinalizerName) { + logger.Info("adding finalizer to source resource") + finalizers := append(sourceObj.GetFinalizers(), constants.FinalizerName) + sourceObj.SetFinalizers(finalizers) if err := r.Update(ctx, source); err != nil { logger.Error(err, "failed to add finalizer") return ctrl.Result{}, err } - logger.V(1).Info("added finalizer") + logger.Info("finalizer added") + // Requeue to continue with reconciliation after finalizer is added + return ctrl.Result{Requeue: true}, nil } // Get target namespaces @@ -212,57 +236,32 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr return ctrl.Result{}, nil } -// handleDeletion removes finalizer after cleaning up all mirrors. -func (r *SourceReconciler) handleDeletion(ctx context.Context, source runtime.Object, sourceObj metav1.Object) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - // source (*unstructured.Unstructured) already implements client.Object - sourceUnstructured := source.(*unstructured.Unstructured) - if !controllerutil.ContainsFinalizer(sourceUnstructured, constants.FinalizerName) { - return ctrl.Result{}, nil - } - - // Delete all mirrors - if err := r.deleteAllMirrors(ctx, sourceObj); err != nil { - logger.Error(err, "failed to delete mirrors") - return ctrl.Result{}, err - } - - // Remove finalizer - controllerutil.RemoveFinalizer(sourceUnstructured, constants.FinalizerName) - if err := r.Update(ctx, sourceUnstructured); err != nil { - logger.Error(err, "failed to remove finalizer") - return ctrl.Result{}, err - } - - logger.Info("finalizer removed, mirrors deleted") - return ctrl.Result{}, nil -} - // handleDisabled removes mirrors when a resource is disabled. func (r *SourceReconciler) handleDisabled(ctx context.Context, sourceObj metav1.Object) (ctrl.Result, error) { logger := log.FromContext(ctx) - // Source is already a client.Object (unstructured implements it) - sourceClient := sourceObj.(client.Object) + // Delete all mirrors for this disabled source + if err := r.deleteAllMirrors(ctx, sourceObj); err != nil { + logger.Error(err, "failed to delete mirrors for disabled resource") + return ctrl.Result{}, err + } - // If resource has finalizer, clean up mirrors and remove it - if controllerutil.ContainsFinalizer(sourceClient, constants.FinalizerName) { - if err := r.deleteAllMirrors(ctx, sourceObj); err != nil { - logger.Error(err, "failed to delete mirrors for disabled resource") - return ctrl.Result{}, err - } + // Remove finalizer if present + if containsString(sourceObj.GetFinalizers(), constants.FinalizerName) { + logger.Info("removing finalizer from disabled resource") + finalizers := removeString(sourceObj.GetFinalizers(), constants.FinalizerName) + sourceObj.SetFinalizers(finalizers) - // Remove finalizer - controllerutil.RemoveFinalizer(sourceClient, constants.FinalizerName) - if err := r.Update(ctx, sourceClient); err != nil { + // Get the unstructured object to update - sourceObj is already *unstructured.Unstructured + source := sourceObj.(*unstructured.Unstructured) + if err := r.Update(ctx, source); err != nil { logger.Error(err, "failed to remove finalizer from disabled resource") return ctrl.Result{}, err } - - logger.Info("mirrors deleted and finalizer removed for disabled resource") + logger.V(1).Info("finalizer removed from disabled resource") } + logger.V(1).Info("mirrors deleted for disabled resource") return ctrl.Result{}, nil } @@ -559,12 +558,10 @@ func (r *SourceReconciler) SetupWithManagerForResourceType( obj := &unstructured.Unstructured{} obj.SetGroupVersionKind(gvk) - // Create unique controller name including version to avoid collisions - // e.g., "HorizontalPodAutoscaler.v1.autoscaling" - controllerName := gvk.Kind + "." + gvk.Version - if gvk.Group != "" { - controllerName += "." + gvk.Group - } + // Create unique controller name including version and group to avoid collisions + // e.g., "HorizontalPodAutoscaler.v1.autoscaling" or "Secret.v1." (empty group for core resources) + // This matches the naming convention used by mirror reconcilers + controllerName := gvk.Kind + "." + gvk.Version + "." + gvk.Group // Create mirror object for watching mirrorObj := &unstructured.Unstructured{} @@ -613,3 +610,24 @@ func (r *SourceReconciler) mapMirrorToSource(ctx context.Context, obj client.Obj }, } } + +// containsString checks if a slice contains a string. +func containsString(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} + +// removeString removes a string from a slice. +func removeString(slice []string, s string) []string { + result := make([]string, 0, len(slice)) + for _, item := range slice { + if item != s { + result = append(result, item) + } + } + return result +} diff --git a/pkg/transformer/transformer.go b/pkg/transformer/transformer.go index c6efff4..eb42d32 100644 --- a/pkg/transformer/transformer.go +++ b/pkg/transformer/transformer.go @@ -3,6 +3,7 @@ package transformer import ( "bytes" "context" + "encoding/base64" "fmt" "strings" "text/template" @@ -409,7 +410,20 @@ func setNestedField(obj map[string]interface{}, path []string, value interface{} return fmt.Errorf("cannot set key %s on non-map %T", finalSegment, current) } - currentMap[finalSegment] = value + // Special handling for Secret data fields + // Secrets require base64-encoded values in .data field + finalValue := value + if isSecretDataField(obj, path) { + // Convert value to string and base64-encode it + strValue, ok := value.(string) + if !ok { + // Try to convert to string + strValue = fmt.Sprintf("%v", value) + } + finalValue = base64Encode(strValue) + } + + currentMap[finalSegment] = finalValue return nil } @@ -512,3 +526,27 @@ func templateFuncs() template.FuncMap { }, } } + +// isSecretDataField checks if the path points to a Secret's .data field. +func isSecretDataField(obj map[string]interface{}, path []string) bool { + // Check if this is a Secret by looking at apiVersion and kind + kind, hasKind := obj["kind"] + apiVersion, hasAPI := obj["apiVersion"] + + if !hasKind || !hasAPI { + return false + } + + // Check if it's a Secret (kind=Secret, apiVersion=v1) + if kind != "Secret" || apiVersion != "v1" { + return false + } + + // Check if path starts with "data" + return len(path) >= 1 && path[0] == "data" +} + +// base64Encode encodes a string to base64. +func base64Encode(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +}