mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-05 22:43:51 +00:00
Reliabity improvements.
This commit is contained in:
@@ -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"
|
||||
|
||||
+20
-5
@@ -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{
|
||||
|
||||
+98
-24
@@ -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 <type> <name> <namespace1> <namespace2> ...
|
||||
|
||||
## 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)
|
||||
|
||||
+889
-122
File diff suppressed because it is too large
Load Diff
Executable
+207
@@ -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 "$@"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user