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