Compare commits

..
54 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub d97132a111 Update go.mod and go.sum (#43) 2026-05-22 05:32:49 +01:00
lukaszraczyloandGitHub 3ab86c34b6 Update go.mod and go.sum (#42) 2026-05-21 05:37:43 +01:00
lukaszraczylo 76237c6bf6 docs: add Telemetry section linking to oss-telemetry opt-out docs
Discloses the single anonymous adoption ping sent on startup and points
users to the upstream README section for full opt-out instructions
instead of duplicating the table here.
2026-05-21 04:06:40 +01:00
lukaszraczylo d552e45e2f feat: anonymous usage telemetry via oss-telemetry
Send a single fire-and-forget ping at startup to help track adoption
and version spread. No persistent identifiers are collected.

Adds main.Version var (defaulting to "dev") so the existing goreleaser
ldflags injection (-X main.Version={{.Version}}) now binds to a real
symbol.

Opt out via any of:
  DO_NOT_TRACK=1
  OSS_TELEMETRY_DISABLED=1
  KUBEMIRROR_DISABLE_TELEMETRY=1
2026-05-21 03:05:58 +01:00
lukaszraczyloandGitHub 1836381d9d Update go.mod and go.sum (#41) 2026-05-20 05:33:53 +01:00
lukaszraczyloandGitHub 9240167386 Update go.mod and go.sum (#40) 2026-05-13 05:26:51 +01:00
lukaszraczyloandGitHub 5e360d80c6 Update go.mod and go.sum (#39) 2026-05-12 05:23:43 +01:00
lukaszraczyloandGitHub e5a0846165 Update go.mod and go.sum (#38) 2026-05-10 05:28:40 +01:00
lukaszraczyloandGitHub 64b14b84e0 Update go.mod and go.sum (#37) 2026-05-09 05:16:48 +01:00
lukaszraczyloandGitHub 2a1575f5c9 Update go.mod and go.sum (#36) 2026-05-08 05:11:10 +01:00
lukaszraczyloandGitHub 8feb93146b Update go.mod and go.sum (#35) 2026-05-06 05:22:35 +01:00
lukaszraczyloandGitHub d055f11bd4 Update go.mod and go.sum (#34) 2026-05-05 05:11:34 +01:00
lukaszraczyloandGitHub e886143429 Update go.mod and go.sum (#33) 2026-05-03 05:26:25 +01:00
lukaszraczylo ba0797f5af chore: remove unreachable SetupWithManager(secret-only) on SourceReconciler
All registration goes through SetupWithManagerForResourceType (eager and
lazy paths in main.go); the legacy single-resource SetupWithManager and
its corev1 import were dead code that misled readers about how the
controller wires up.
2026-05-02 22:50:02 +01:00
lukaszraczylo 75f7c18f3c fix: hash drift, transformer leak guard, prod logger, ctx-aware wait
M7: extractUnstructuredContent only hashed 'spec' when present, dropping
all other top-level content fields. Resources with both spec and data
(or any non-spec content) silently drifted until the next 10m resync.
Now hashes every non-Kubernetes-managed top-level field, matching the
fields updateUnstructuredMirror copies.

M6: when a source has a transform annotation, also hash the source's
labels and annotations (filtered of kubemirror.raczylo.com/* keys to
avoid the controller's own bookkeeping churning the hash). Templates
read these via TransformContext; without this a label change wouldn't
re-render the transformed mirror.

H3: text/template.Execute is not context-aware, so applyTemplateRule's
timeout cancels the select but leaks the executor goroutine. Added a
process-wide semaphore (cap 64) so a runaway template can't spawn an
unbounded number of stuck goroutines on every reconcile.

M4: zap dev mode (DPanic-on-error, console output, stacktraces on
warning) was hardcoded on. Defaulted to production; --zap-devel flag
remains for opt-in.

M5: WaitForInitialDiscovery was anchored on context.Background() with
its own WithTimeout, so SIGTERM during startup couldn't abort the wait.
Now anchors on signalCtx.
2026-05-02 22:49:15 +01:00
lukaszraczylo cf095e93f4 fix(dynamic-manager): release mu before invoking registration
H2: scanAndRegister held d.mu (write lock) across registerController
and registerMirrorControllerOnly. Those calls enter controller-runtime's
manager state machine, which takes its own internal locks and can block
on cache sync — holding our application-level write lock across them
is a latent deadlock the moment any reentrant access happens (health
checks reading GetRegisteredCount, factories that introspect state).

Restructured into three phases: snapshot work under RLock, perform
registrations with NO lock held, then commit results under Lock.
Registration step routed through funcs to keep tests honest about
the lock state at the moment of invocation.
2026-05-02 22:45:27 +01:00
lukaszraczylo a8e48a9eb6 fix(controller): forward APIReader+CircuitBreaker through NamespaceReconciler
H4: NamespaceReconciler.reconcileMirror builds an ad-hoc SourceReconciler
to delegate mirror creation. The previous version left APIReader and
CircuitBreaker as nil, which silently disabled freshness verification
on the namespace-driven path (a label change to a target namespace
would mirror cached, possibly stale source data) and bypassed circuit
breaker accounting for those reconciles.

Construction extracted into newSourceReconciler so the forwarding is
covered by a unit test that pins both fields by identity.
2026-05-02 22:41:11 +01:00
lukaszraczylo dfe08b35d1 fix(controller): stop self-triggered reconcile loops
C2: updateLastSyncStatus wrote the sync-status annotation on every
successful reconcile. Because the source's watch predicate is the
'enabled' label (server-side filter), that Update fires a watch event
that re-enters Reconcile. With reconciled/error counts varying across
cycles, the value differs each time, so the API server bumps RV and
the loop never quiesces. Now skips the Update when the value matches
the existing annotation.

C3: NamespaceReconciler's happy-path returned RequeueAfter=3s
unconditionally. Every namespace in the cluster re-reconciled every
3 seconds forever, generating constant List calls per source kind.
Now returns ctrl.Result{}; cache-staleness windows are handled by
the manager's resync period and source freshness verification.
2026-05-02 22:39:09 +01:00
lukaszraczylo 99c0eccd53 fix: default verify-source-freshness=true; honor opt-out for glob
H1: --verify-source-freshness used to default to false, so any source
update whose annotation was still in the informer cache (5-20s lag)
would resolve the wrong target list. cleanupOrphanedMirrors then ran
against the stale list and missed orphans (manifested in e2e as
'Orphaned mirror in kubemirror-e2e-app-1 not deleted within timeout'
after target-namespaces was changed). Defaulting to true fixes the
race; the trade-off is one extra API read per stale-cache reconcile.

M2: ResolveTargetNamespaces glob branch checked filter.IsAllowed but
not the opt-out map, so a namespace labeled allow-mirrors=false would
still receive a mirror through patterns like 'app-*'. The 'all' branch
already had the guard; the glob branch now does too. Direct namespace
listings still bypass opt-out by design (explicit author intent).
2026-05-02 22:36:50 +01:00
lukaszraczylo 4277c8ac39 fix(controller): guard mirror deletion + enforce secret blacklist
C1: deleteAllMirrors used to issue a blind Delete on every namespace
matching the source name+GVK, which would destroy unrelated resources
(e.g. a 'default' SA, 'ca-bundle' ConfigMap) sharing the source name.
Now reads each candidate, verifies managed-by label and source-reference
annotation, and only deletes confirmed mirrors.

M1: BlacklistedSecretTypes was declared but never enforced. Enabling
mirroring on a service-account-token / bootstrap-token / helm release
Secret would mirror credentials cluster-wide. Now refused at Reconcile.

M3: deleteAllMirrors swallowed per-namespace errors and returned nil,
so callers removed the finalizer even on partial failure (orphans).
Errors are now joined and returned.
2026-05-02 22:35:40 +01:00
lukaszraczylo b555d84d32 ci: bump go-version constraint to >=1.26
k8s.io/api v0.36.0 requires Go 1.26.0; autoupdate has been failing
since 2026-04-23 because setup-go's cached 1.25.9 fell behind.
2026-05-02 22:03:19 +01:00
lukaszraczyloandGitHub 30ffc823d9 Update go.mod and go.sum (#32) 2026-04-19 05:09:27 +01:00
lukaszraczyloandGitHub c3450e2af2 Update go.mod and go.sum (#31) 2026-04-17 05:09:22 +01:00
lukaszraczyloandGitHub d0857e4f4a Update go.mod and go.sum (#30) 2026-04-16 05:10:11 +01:00
lukaszraczyloandGitHub 36c9e859af Update go.mod and go.sum (#29) 2026-04-15 05:07:38 +01:00
lukaszraczyloandGitHub 05aacddeab Update go.mod and go.sum (#28) 2026-04-10 05:08:23 +01:00
lukaszraczyloandGitHub a9838e9156 Update go.mod and go.sum (#27) 2026-04-09 05:02:31 +01:00
lukaszraczyloandGitHub 317143c458 Update go.mod and go.sum (#26) 2026-03-31 05:04:13 +01:00
lukaszraczyloandGitHub 42358a3743 Update go.mod and go.sum (#25) 2026-03-20 03:54:18 +00:00
lukaszraczyloandGitHub f2ecc5d56a Update go.mod and go.sum (#24) 2026-03-19 03:57:48 +00:00
lukaszraczyloandGitHub 30de592c0c Update go.mod and go.sum (#23) 2026-03-18 03:57:33 +00:00
lukaszraczyloandGitHub ea405f44a2 Update go.mod and go.sum (#22) 2026-03-12 03:52:50 +00:00
lukaszraczyloandGitHub f62bec57f5 Update go.mod and go.sum (#21) 2026-03-09 03:53:52 +00:00
lukaszraczyloandGitHub c9cf81e4fd Update go.mod and go.sum (#20) 2026-03-07 03:46:46 +00:00
lukaszraczyloandGitHub 7c8a12958d Update go.mod and go.sum (#19) 2026-03-06 03:52:51 +00:00
lukaszraczyloandGitHub 0bac0f4645 Update go.mod and go.sum (#18) 2026-03-05 03:52:56 +00:00
lukaszraczyloandGitHub afe6602ddd Update go.mod and go.sum (#17) 2026-03-03 03:53:14 +00:00
lukaszraczyloandGitHub 91a1d05a92 Update go.mod and go.sum (#16) 2026-03-01 03:59:02 +00:00
lukaszraczyloandGitHub 3c7f25bc16 Update go.mod and go.sum (#15) 2026-02-28 03:44:26 +00:00
lukaszraczyloandGitHub 1a40783e36 Update go.mod and go.sum (#14) 2026-02-26 03:54:10 +00:00
lukaszraczyloandGitHub 56809700fb Update go.mod and go.sum (#13) 2026-02-11 04:03:15 +00:00
lukaszraczyloandGitHub 5db9fa8653 Update go.mod and go.sum (#12) 2026-02-10 04:04:06 +00:00
lukaszraczyloandGitHub 1e4af7df0c Update go.mod and go.sum (#11) 2026-02-09 03:59:24 +00:00
lukaszraczyloandGitHub 668a84b070 Update go.mod and go.sum (#10) 2026-02-07 03:51:55 +00:00
lukaszraczyloandGitHub 9dea7a1022 Update go.mod and go.sum (#9) 2026-01-28 03:40:46 +00:00
lukaszraczyloandGitHub ba95e09f5c Update go.mod and go.sum (#8) 2026-01-27 03:42:08 +00:00
lukaszraczyloandGitHub 53afeb8560 Update go.mod and go.sum (#7) 2026-01-20 03:39:57 +00:00
lukaszraczyloandGitHub 096dca47d1 improvements jan2025 (#6)
* feat(controller): add lazy watcher, improve resource usage and add pattern validation

- [x] Add cache sync health check for readiness probe verification
- [x] Create namespace lister with API reader support for fresh label queries
- [x] Add pattern validation with warning logs for invalid glob patterns
- [x] Implement lazy watcher initialization mode to scan for active resources
- [x] Add requeue delay to namespace reconciler for cache settlement
- [x] Replace custom containsString with slices.Contains from stdlib
- [x] Add structured logging context to reconcilers (kind, group, version)
- [x] Improve error variable naming for clarity in nested conditions
- [x] Add nil-safe label access in namespace reconciler setup
- [x] Add APIReader to namespace and source reconcilers for fresh data
- [x] Improve type assertions with proper error handling in mirror operations
- [x] Reorder struct fields for consistency and readability
- [x] Add comprehensive pattern validation tests and validation API

* feat(controller): add lazy watcher, improve resource usage and add pattern validation

- [x] Add circuit breaker for reconciliation failure tracking and prevention
- [x] Implement granular registration state tracking (not-registered, source-only, fully-registered)
- [x] Add lazy controller initialization for active resource types only
- [x] Consolidate namespace listing into single API call for efficiency
- [x] Add mirror creation verification to catch webhook rejections
- [x] Implement high-cardinality resource detection and warnings
- [x] Add source deletion check in mirror reconciler to prevent races
- [x] Preserve transformation annotations on errors in mirror reconciliation
- [x] Expand constants documentation with labels vs annotations design rationale
- [x] Add comprehensive test coverage for circuit breaker and registration states
- [x] Add mutation-safety tests for hash computation

* fixup! feat(controller): add lazy watcher, improve resource usage and add pattern validation
2026-01-14 13:07:11 +00:00
lukaszraczyloandGitHub 4f8e2783cf Update go.mod and go.sum (#5) 2026-01-13 03:38:38 +00:00
lukaszraczyloandGitHub 0f74af4a07 Update go.mod and go.sum (#4) 2026-01-10 03:37:16 +00:00
lukaszraczyloandGitHub a89fcd5726 Update go.mod and go.sum (#3) 2026-01-09 03:39:08 +00:00
lukaszraczyloandGitHub a2aff5671e Update go.mod and go.sum (#2) 2026-01-07 03:39:51 +00:00
lukaszraczyloandGitHub 1cda7c46be Update go.mod and go.sum (#1) 2026-01-06 03:38:29 +00:00
lukaszraczylo 19e72e136a Add lazy watcher, improving resource usage; update website. 2025-12-27 01:28:46 +00:00
36 changed files with 4579 additions and 773 deletions
+1 -1
View File
@@ -15,6 +15,6 @@ jobs:
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
with:
go-version: ">=1.25"
go-version: ">=1.26"
release-workflow: "release.yaml"
secrets: inherit
+1 -1
View File
@@ -19,5 +19,5 @@ jobs:
pr-checks:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with:
go-version: ">=1.25"
go-version: ">=1.26"
secrets: inherit
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ">=1.25"
go-version: ">=1.26"
- name: Install dependencies
run: go mod download
@@ -45,7 +45,7 @@ jobs:
needs: e2e-tests
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: ">=1.25"
go-version: ">=1.26"
docker-enabled: true
secrets: inherit
+2 -1
View File
@@ -51,7 +51,8 @@ archives:
- examples/*
format_overrides:
- goos: windows
format: zip
formats:
- zip
checksum:
name_template: 'checksums.txt'
+13
View File
@@ -970,6 +970,19 @@ git push origin v0.2.0
- [Helm Chart Documentation](charts/kubemirror/README.md) - Kubernetes deployment via Helm
- [GitHub Repository](https://github.com/lukaszraczylo/kubemirror) - Source code and issue tracker
## Telemetry
On startup this controller sends a single anonymous adoption ping — project
name, version, timestamp; no identifiers, no Kubernetes object data, no
cluster metadata. Fire-and-forget with a 2-second timeout; cannot block
startup or panic.
See **[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**
for the exact wire format, source, and full opt-out documentation.
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
or `KUBEMIRROR_DISABLE_TELEMETRY=1`.
## License
See [LICENSE](LICENSE) file for details.
@@ -46,6 +46,10 @@ spec:
{{- if .Values.controller.verifySourceFreshness }}
- --verify-source-freshness=true
{{- end }}
{{- if .Values.controller.lazyWatcherInit }}
- --lazy-watcher-init=true
{{- end }}
- --watcher-scan-interval={{ .Values.controller.watcherScanInterval }}
{{- if .Values.controller.excludedNamespaces }}
- --excluded-namespaces={{ .Values.controller.excludedNamespaces }}
{{- end }}
@@ -56,6 +60,7 @@ spec:
- --resource-types={{ join "," .Values.controller.resourceTypes }}
{{- end }}
- --discovery-interval={{ .Values.controller.discoveryInterval }}
- --resync-period={{ .Values.controller.resyncPeriod }}
ports:
- name: metrics
containerPort: 8080
+22 -1
View File
@@ -44,14 +44,21 @@ controller:
leaderElectionID: "kubemirror-controller-leader"
# Resource types to mirror
# Examples: ["Secret.v1", "ConfigMap.v1", "Ingress.v1.networking.k8s.io"]
# Examples: ["Secret.v1", "ConfigMap.v1", "Ingress.v1.networking.k8s.io", "Middleware.v1alpha1.traefik.io"]
# If empty, auto-discovery will find all mirrorable resources
# MEMORY TIP: Specifying exact types reduces memory by 70-80% vs auto-discovery
# Common types: Secret.v1, ConfigMap.v1
resourceTypes: []
# Auto-discovery interval (only used when resourceTypes is empty)
# How often to rediscover available resources in the cluster
discoveryInterval: "5m"
# Cache resync period - how often to refresh all cached resources
# Higher values reduce memory churn and API load
# Default: 10m (was 30s in earlier versions)
resyncPeriod: "10m"
# Resource limits
maxTargets: 100
workerThreads: 5
@@ -66,6 +73,20 @@ controller:
# Recommended: false for most deployments (eventual consistency is acceptable)
verifySourceFreshness: false
# Lazy watcher initialization (RECOMMENDED for production)
# Only creates informers for resource types that actually have resources marked for mirroring
# Dramatically reduces memory usage - e.g., if you have 204 available resource types but only
# 2 types with marked resources, this creates only 2 watchers instead of 204
# Memory savings: typically 70-90% compared to eager initialization
# Default: false (user opt-in)
lazyWatcherInit: false
# Watcher scan interval (lazy-watcher-init mode only)
# How often to scan the cluster for new resource types that need watchers
# If you add a new resource type to mirror, it will be detected within this interval
# Default: 5m
watcherScanInterval: "5m"
# Namespace filtering
excludedNamespaces: ""
includedNamespaces: ""
+241 -57
View File
@@ -3,25 +3,38 @@ package main
import (
"context"
"errors"
"flag"
"net/http"
"os"
"time"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"github.com/lukaszraczylo/kubemirror/pkg/circuitbreaker"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/controller"
"github.com/lukaszraczylo/kubemirror/pkg/discovery"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
telemetry "github.com/lukaszraczylo/oss-telemetry"
)
// Version is the build version. Set via ldflags during build:
//
// -X main.Version=v1.2.3
var Version = "dev"
var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
@@ -31,7 +44,27 @@ func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
}
// makeCacheSyncChecker creates a healthz.Checker that verifies informer cache sync.
// This ensures the readiness probe fails if caches are not synced.
func makeCacheSyncChecker(c cache.Cache, ctx context.Context, logger logr.Logger) healthz.Checker {
return func(_ *http.Request) error {
// WaitForCacheSync returns true immediately if already synced,
// or waits until sync completes or context is cancelled.
// With a short context timeout, this provides a quick check.
checkCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
if !c.WaitForCacheSync(checkCtx) {
logger.V(1).Info("informer caches not yet synced")
return errors.New("informer caches not synced")
}
return nil
}
}
func main() {
telemetry.Send("kubemirror", Version)
var (
metricsAddr string
probeAddr string
@@ -47,6 +80,8 @@ func main() {
rateLimitBurst int
resyncPeriod time.Duration
verifySourceFreshness bool
lazyWatcherInit bool
watcherScanInterval time.Duration
)
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
@@ -73,15 +108,25 @@ func main() {
"QPS rate limit for API server requests.")
flag.IntVar(&rateLimitBurst, "rate-limit-burst", 100,
"Burst limit for API server requests.")
flag.DurationVar(&resyncPeriod, "resync-period", 30*time.Second,
flag.DurationVar(&resyncPeriod, "resync-period", 10*time.Minute,
"Period for resyncing all resources (catches updates missed due to informer cache delays).")
flag.BoolVar(&verifySourceFreshness, "verify-source-freshness", false,
flag.BoolVar(&verifySourceFreshness, "verify-source-freshness", true,
"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.")
"Prevents mirroring stale data and missed orphan cleanups when the informer "+
"cache lags behind watch events. Trade-off: one extra API call per reconcile "+
"when the cache is stale. Disable only if you are confident your cluster's "+
"watch latency is negligible.")
flag.BoolVar(&lazyWatcherInit, "lazy-watcher-init", false,
"Enable lazy watcher initialization - only create informers for resource types that have resources marked for mirroring. "+
"Significantly reduces memory usage by avoiding watchers for unused resource types. "+
"Recommended for production environments with many unused resource types.")
flag.DurationVar(&watcherScanInterval, "watcher-scan-interval", 5*time.Minute,
"Interval for scanning cluster to detect new resource types needing watchers (lazy-watcher-init mode only).")
// Default to production logger (JSON output, no DPanic-on-error). Operators
// can opt into development mode via the --zap-devel flag bound below.
opts := zap.Options{
Development: true,
Development: false,
}
opts.BindFlags(flag.CommandLine)
flag.Parse()
@@ -132,6 +177,14 @@ func main() {
"included", includedList,
)
// Create circuit breaker for reconciliation failures
cb := circuitbreaker.NewWithDefaults()
setupLog.Info("circuit breaker initialized",
"failureThreshold", 5,
"resetTimeout", "5m",
"halfOpenSuccessThreshold", 2,
)
// Parse and configure resource types
var mirroredResources []config.ResourceType
if resourceTypes != "" {
@@ -150,7 +203,34 @@ func main() {
cfg.MirroredResourceTypes = mirroredResources
// Set up controller manager
// Create cache transform function to strip unnecessary fields and reduce memory usage
// This can reduce memory consumption by 50-70% by removing:
// - managedFields (often several KB per resource)
// - large annotations like kubectl.kubernetes.io/last-applied-configuration
transformFunc := func(obj interface{}) (interface{}, error) {
// Type assert to unstructured
u, ok := obj.(*unstructured.Unstructured)
if !ok {
return obj, nil // Not unstructured, return as-is
}
// Strip managedFields - can be several KB per resource
u.SetManagedFields(nil)
// Strip large annotations that we don't need for reconciliation
annotations := u.GetAnnotations()
if annotations != nil {
// Remove kubectl last-applied-configuration (can be very large)
delete(annotations, "kubectl.kubernetes.io/last-applied-configuration")
// Remove other large annotations we don't need
delete(annotations, "deployment.kubernetes.io/revision")
u.SetAnnotations(annotations)
}
return obj, nil
}
// Set up controller manager with cache configuration
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{
@@ -162,19 +242,41 @@ func main() {
LeaseDuration: &cfg.LeaderElection.LeaseDuration,
RenewDeadline: &cfg.LeaderElection.RenewDeadline,
RetryPeriod: &cfg.LeaderElection.RetryPeriod,
Cache: cache.Options{
// Use the transform function to reduce memory usage
DefaultTransform: transformFunc,
// Increase the resync period to reduce memory churn
SyncPeriod: &resyncPeriod,
},
})
if err != nil {
setupLog.Error(err, "unable to create manager")
os.Exit(1)
}
// Note on Field Indexes:
// Field indexes in controller-runtime can improve performance for in-cache lookups.
// For kubemirror, potential indexes include:
// 1. metadata.labels[kubemirror.raczylo.com/enabled] - for finding enabled resources
// 2. annotations[kubemirror.raczylo.com/source-uid] - for finding mirrors by source
//
// However, these are not implemented because:
// - Server-side filtering via label selectors already handles enabled label filtering efficiently
// - Mirror-to-source lookups are currently done by listing all managed resources
// - Dynamic resource types (unstructured) make index setup more complex
// - Benchmark testing is required to verify indexes improve performance before adding complexity
//
// If benchmarks show indexes would help, use:
// mgr.GetFieldIndexer().IndexField(ctx, &unstructured.Unstructured{...}, indexPath, extractFunc)
// Set up signal handler context for graceful shutdown
signalCtx := ctrl.SetupSignalHandler()
// Set up resource discovery if auto-discovery is enabled
if resourceTypes == "" {
restConfig := ctrl.GetConfigOrDie()
discoveryClient, err := discovery.NewResourceDiscovery(restConfig)
var discoveryClient *discovery.ResourceDiscovery
discoveryClient, err = discovery.NewResourceDiscovery(restConfig)
if err != nil {
setupLog.Error(err, "unable to create discovery client")
os.Exit(1)
@@ -183,15 +285,16 @@ func main() {
discoveryMgr := discovery.NewManager(discoveryClient, discoveryInterval)
// Start discovery manager with signal-aware context
if err := discoveryMgr.Start(signalCtx); err != nil {
err = discoveryMgr.Start(signalCtx)
if err != nil {
setupLog.Error(err, "unable to start discovery manager")
os.Exit(1)
}
// Wait for initial discovery with 30s timeout
waitCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := discoveryMgr.WaitForInitialDiscovery(waitCtx, 30*time.Second); err != nil {
// Wait for initial discovery with 30s timeout, anchored on the signal
// context so SIGTERM during startup actually aborts the wait.
err = discoveryMgr.WaitForInitialDiscovery(signalCtx, 30*time.Second)
if err != nil {
setupLog.Error(err, "timeout waiting for initial resource discovery")
os.Exit(1)
}
@@ -206,54 +309,125 @@ func main() {
)
}
// Create namespace lister
namespaceLister := controller.NewKubernetesNamespaceLister(mgr.GetClient())
// Create namespace lister with API reader for fresh namespace lookups.
// This ensures label-based queries (allow-mirrors label) return fresh data
// and don't suffer from informer cache staleness after label changes.
namespaceLister := controller.NewKubernetesNamespaceListerWithAPIReader(
mgr.GetClient(),
mgr.GetAPIReader(),
)
// Dynamically register controllers for all discovered resource types
// Create a separate reconciler instance for each resource type
for _, rt := range cfg.MirroredResourceTypes {
gvk := rt.GroupVersionKind()
setupLog.Info("registering controller for resource type",
"group", gvk.Group,
"version", gvk.Version,
"kind", gvk.Kind,
// Validate flag combinations and warn about conflicts
if lazyWatcherInit && resourceTypes != "" {
setupLog.Info("WARNING: --resource-types flag is ignored in lazy-watcher-init mode",
"specifiedTypes", resourceTypes,
"reason", "lazy watcher discovers resource types dynamically based on actual usage",
)
// Create a source reconciler instance for this specific resource type
sourceReconciler := &controller.SourceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Config: cfg,
Filter: namespaceFilter,
NamespaceLister: namespaceLister,
GVK: gvk,
APIReader: mgr.GetAPIReader(), // Direct API reader (bypasses cache)
}
if err = sourceReconciler.SetupWithManagerForResourceType(mgr, gvk); err != nil {
setupLog.Error(err, "unable to create source controller",
"resourceType", rt.String(),
)
os.Exit(1)
}
// Create a mirror reconciler instance for orphan detection
// This watches mirrored resources (with managed-by label) and verifies their source still exists
mirrorReconciler := &controller.MirrorReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
GVK: gvk,
}
if err = mirrorReconciler.SetupWithManager(mgr, gvk); err != nil {
setupLog.Error(err, "unable to create mirror controller",
"resourceType", rt.String(),
)
os.Exit(1)
}
}
setupLog.Info("registered source and mirror controllers", "count", len(cfg.MirroredResourceTypes))
// Choose between lazy watcher initialization (scan for active resources) or eager (register all)
if lazyWatcherInit {
setupLog.Info("using lazy watcher initialization",
"availableResourceTypes", len(cfg.MirroredResourceTypes),
"scanInterval", watcherScanInterval,
)
// Factory functions for creating reconcilers
sourceFactory := func(gvk schema.GroupVersionKind) *controller.SourceReconciler {
return &controller.SourceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Config: cfg,
Filter: namespaceFilter,
NamespaceLister: namespaceLister,
GVK: gvk,
APIReader: mgr.GetAPIReader(),
CircuitBreaker: cb,
}
}
mirrorFactory := func(gvk schema.GroupVersionKind) *controller.MirrorReconciler {
return &controller.MirrorReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
GVK: gvk,
}
}
// Create dynamic controller manager
dynamicMgr := controller.NewDynamicControllerManager(controller.DynamicManagerConfig{
Client: mgr.GetClient(),
APIReader: mgr.GetAPIReader(), // Direct API reader for pre-start scans
Manager: mgr,
Config: cfg,
Filter: namespaceFilter,
NamespaceLister: namespaceLister,
AvailableResources: cfg.MirroredResourceTypes,
ScanInterval: watcherScanInterval,
SourceReconcilerFactory: sourceFactory,
MirrorReconcilerFactory: mirrorFactory,
})
// Start dynamic controller manager
err = dynamicMgr.Start(signalCtx)
if err != nil {
setupLog.Error(err, "unable to start dynamic controller manager")
os.Exit(1)
}
setupLog.Info("dynamic controller manager started - controllers will be registered on-demand")
} else {
setupLog.Info("using eager watcher initialization",
"resourceTypes", len(cfg.MirroredResourceTypes),
)
// Eager mode: Register controllers for all discovered resource types upfront
// Create a separate reconciler instance for each resource type
for _, rt := range cfg.MirroredResourceTypes {
gvk := rt.GroupVersionKind()
setupLog.Info("registering controller for resource type",
"group", gvk.Group,
"version", gvk.Version,
"kind", gvk.Kind,
)
// Create a source reconciler instance for this specific resource type
sourceReconciler := &controller.SourceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Config: cfg,
Filter: namespaceFilter,
NamespaceLister: namespaceLister,
GVK: gvk,
APIReader: mgr.GetAPIReader(), // Direct API reader (bypasses cache)
CircuitBreaker: cb,
}
if err = sourceReconciler.SetupWithManagerForResourceType(mgr, gvk); err != nil {
setupLog.Error(err, "unable to create source controller",
"resourceType", rt.String(),
)
os.Exit(1)
}
// Create a mirror reconciler instance for orphan detection
// This watches mirrored resources (with managed-by label) and verifies their source still exists
mirrorReconciler := &controller.MirrorReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
GVK: gvk,
}
if err = mirrorReconciler.SetupWithManager(mgr, gvk); err != nil {
setupLog.Error(err, "unable to create mirror controller",
"resourceType", rt.String(),
)
os.Exit(1)
}
}
setupLog.Info("registered source and mirror controllers", "count", len(cfg.MirroredResourceTypes))
}
// Register namespace reconciler to watch for new namespaces and label changes
namespaceReconciler := &controller.NamespaceReconciler{
@@ -263,6 +437,8 @@ func main() {
Filter: namespaceFilter,
NamespaceLister: namespaceLister,
ResourceTypes: cfg.MirroredResourceTypes,
APIReader: mgr.GetAPIReader(), // Direct API reader for fresh namespace lookups
CircuitBreaker: cb, // Forwarded into reconcileMirror so namespace-driven mirrors share failure throttling
}
if err = namespaceReconciler.SetupWithManager(mgr); err != nil {
@@ -273,11 +449,19 @@ func main() {
setupLog.Info("registered namespace reconciler")
// Add health checks
// Liveness: basic ping to verify the controller process is alive
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up health check")
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
// Readiness: check that informer caches are synced before accepting traffic.
// This prevents reconciliation from running with incomplete/stale cache data.
// The cache sync check ensures all informers have received initial data from the API server.
// Note: The manager automatically waits for cache sync before starting controllers,
// but this check ensures the readiness probe reflects cache state for external monitoring.
cacheReadyCheck := makeCacheSyncChecker(mgr.GetCache(), signalCtx, setupLog)
if err := mgr.AddReadyzCheck("readyz", cacheReadyCheck); err != nil {
setupLog.Error(err, "unable to set up ready check")
os.Exit(1)
}
+353 -314
View File
@@ -9,125 +9,140 @@
content="Copy Secrets, ConfigMaps, and any Custom Resource across Kubernetes namespaces automatically. Transform values per environment. Better replacement for Reflector."
/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class'
}
</script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<style>
body { font-family: "Inter", sans-serif; }
code, pre { font-family: "JetBrains Mono", monospace; }
.theme-transition {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-fade-in-up { animation: fadeInUp 0.6s ease-out; }
.animate-float { animation: float 3s ease-in-out infinite; }
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(17, 24, 39, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-lift:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
.dark .gradient-text {
background: linear-gradient(135deg, #818cf8 0%, #c084fc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.1); }
.dark .shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4); }
.code-block {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
}
/* Fade-in animation */
@keyframes fadeInUp {
from {
.dark .code-block {
background: linear-gradient(135deg, #0f172a 0%, #020617 100%);
}
html { scroll-behavior: smooth; }
.rotating-text {
display: inline-block;
min-width: 120px;
text-align: left;
perspective: 1000px;
}
.word-flip {
animation: flipBoard 0.6s ease-in-out;
transform-style: preserve-3d;
}
@keyframes flipBoard {
0% {
transform: rotateX(0deg);
filter: blur(0px);
opacity: 1;
}
50% {
transform: rotateX(90deg);
filter: blur(4px);
opacity: 0;
}
51% {
transform: rotateX(-90deg);
filter: blur(4px);
opacity: 0;
transform: translateY(30px);
}
to {
100% {
transform: rotateX(0deg);
filter: blur(0px);
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeInUp 0.6s ease-out forwards;
opacity: 0;
}
.delay-100 { animation-delay: 0.1s; }
.delay-200 { animation-delay: 0.2s; }
.delay-300 { animation-delay: 0.3s; }
/* Scroll progress bar */
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
z-index: 9999;
transition: width 0.1s ease-out;
}
/* Mobile menu animation */
.mobile-menu {
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
}
.mobile-menu.active {
transform: translateX(0);
}
/* Smooth hover glow */
.glow-on-hover {
position: relative;
overflow: hidden;
}
.glow-on-hover::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.glow-on-hover:hover::before {
width: 300px;
height: 300px;
}
</style>
<script>
if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>
</head>
<body class="bg-gradient-to-br from-slate-50 to-blue-50">
<!-- Scroll Progress Bar -->
<div class="progress-bar" id="progressBar"></div>
<body class="bg-gradient-to-br from-slate-50 to-blue-50 dark:from-gray-900 dark:to-gray-800 text-gray-900 dark:text-gray-100 theme-transition">
<!-- Navigation -->
<nav class="bg-white/90 backdrop-blur-lg shadow-lg sticky top-0 z-50 border-b border-blue-100">
<nav class="fixed w-full glass shadow-modern z-50 theme-transition">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16 items-center">
<a href="#" class="flex items-center gap-3 group">
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-2 rounded-lg group-hover:scale-110 transition-transform duration-300">
<a href="#" class="flex items-center gap-3 hover:opacity-80 transition-opacity duration-300">
<div class="bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700 p-2 rounded-lg transition-transform duration-300 hover:scale-110">
<i class="fas fa-copy text-2xl text-white"></i>
</div>
<span class="text-2xl font-bold gradient-text">KubeMirror</span>
</a>
<!-- Desktop Menu -->
<div class="hidden md:flex space-x-8">
<a href="#problem" class="text-slate-700 hover:text-blue-600 font-medium transition-colors">Problem</a>
<a href="#features" class="text-slate-700 hover:text-blue-600 font-medium transition-colors">Features</a>
<a href="#examples" class="text-slate-700 hover:text-blue-600 font-medium transition-colors">Examples</a>
<a href="#comparison" class="text-slate-700 hover:text-blue-600 font-medium transition-colors">Compare</a>
<a href="#installation" class="text-slate-700 hover:text-blue-600 font-medium transition-colors">Install</a>
<div class="hidden md:flex space-x-6">
<a href="#problem" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 font-medium theme-transition">Problem</a>
<a href="#features" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 font-medium theme-transition">Features</a>
<a href="#examples" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 font-medium theme-transition">Examples</a>
<a href="#comparison" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 font-transition">Compare</a>
<a href="#installation" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 font-medium theme-transition">Install</a>
</div>
<div class="flex items-center space-x-4">
<a href="https://github.com/lukaszraczylo/kubemirror" target="_blank" class="text-slate-700 hover:text-blue-600 transition-colors">
<button id="theme-toggle" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center theme-transition" aria-label="Toggle theme">
<i class="fas fa-moon dark:hidden text-xl"></i>
<i class="fas fa-sun hidden dark:inline text-xl"></i>
</button>
<a href="https://github.com/lukaszraczylo/kubemirror" target="_blank" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center theme-transition" aria-label="View on GitHub">
<i class="fab fa-github text-2xl"></i>
</a>
<!-- Mobile Menu Button -->
<button id="mobileMenuBtn" class="md:hidden text-slate-700 hover:text-blue-600">
<button id="mobileMenuBtn" class="md:hidden text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
<i class="fas fa-bars text-2xl"></i>
</button>
</div>
@@ -136,39 +151,39 @@
</nav>
<!-- Mobile Menu -->
<div id="mobileMenu" class="mobile-menu fixed top-16 right-0 w-64 h-full bg-white shadow-2xl z-40 md:hidden">
<div id="mobileMenu" class="mobile-menu fixed top-16 right-0 w-64 h-full bg-white dark:bg-gray-800 shadow-2xl z-40 md:hidden transform translate-x-full transition-transform duration-300 theme-transition">
<div class="flex flex-col p-6 space-y-4">
<a href="#problem" class="text-slate-700 hover:text-blue-600 font-medium transition-colors py-2">Problem</a>
<a href="#features" class="text-slate-700 hover:text-blue-600 font-medium transition-colors py-2">Features</a>
<a href="#examples" class="text-slate-700 hover:text-blue-600 font-medium transition-colors py-2">Examples</a>
<a href="#comparison" class="text-slate-700 hover:text-blue-600 font-medium transition-colors py-2">Compare</a>
<a href="#installation" class="text-slate-700 hover:text-blue-600 font-medium transition-colors py-2">Install</a>
<a href="#problem" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 font-medium py-2 theme-transition">Problem</a>
<a href="#features" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 font-medium py-2 theme-transition">Features</a>
<a href="#examples" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 font-medium py-2 theme-transition">Examples</a>
<a href="#comparison" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 font-medium py-2 theme-transition">Compare</a>
<a href="#installation" class="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 font-medium py-2 theme-transition">Install</a>
</div>
</div>
<!-- Hero Section -->
<section class="relative overflow-hidden py-24">
<div class="absolute inset-0 bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50 opacity-70"></div>
<section class="relative overflow-hidden py-24 pt-32">
<div class="absolute inset-0 bg-gradient-to-br from-blue-50/50 via-purple-50/50 to-pink-50/50 dark:from-blue-900/20 dark:via-purple-900/20 dark:to-pink-900/20"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="mb-8 inline-block fade-in">
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-6 rounded-2xl shadow-2xl">
<div class="mb-8 inline-block animate-fade-in-up">
<div class="bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700 p-6 rounded-2xl shadow-2xl animate-float">
<i class="fas fa-copy text-7xl text-white"></i>
</div>
</div>
<h1 class="text-5xl md:text-6xl font-extrabold text-slate-900 mb-6 leading-tight fade-in delay-100">
Copy Kubernetes Resources<br/>
<h1 class="text-3xl sm:text-2xl sm:text-xl sm:text-2xl lg:text-6xl font-bold text-gray-900 dark:text-white mb-4 sm:mb-6 leading-tight animate-fade-in-up theme-transition" style="animation-delay: 0.1s;">
<span id="rotatingWord" class="rotating-text">Copy</span> Kubernetes Resources<br/>
<span class="gradient-text">Across Namespaces</span>
</h1>
<p class="text-xl md:text-2xl text-slate-600 mb-10 max-w-3xl mx-auto leading-relaxed fade-in delay-200">
<p class="text-base sm:text-base text-gray-600 dark:text-gray-300 mb-8 sm:mb-10 max-w-3xl mx-auto leading-relaxed animate-fade-in-up theme-transition" style="animation-delay: 0.2s;">
Share Secrets, ConfigMaps, and any Custom Resource (like Traefik Middleware, Cert-Manager Certificates) across multiple namespaces.
<strong>Automatically keep them in sync.</strong> Transform values per environment.
</p>
<div class="flex flex-col sm:flex-row justify-center gap-6 fade-in delay-300">
<a href="#installation" class="bg-gradient-to-r from-blue-600 to-purple-600 text-white px-10 py-4 rounded-xl font-bold text-lg hover:from-blue-700 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl hover:scale-105 glow-on-hover">
<div class="flex flex-col sm:flex-row justify-center gap-6 animate-fade-in-up" style="animation-delay: 0.3s;">
<a href="#installation" class="bg-gradient-to-r from-blue-600 to-purple-600 dark:from-blue-500 dark:to-purple-500 text-white px-10 py-4 rounded-xl font-bold text-lg hover:from-blue-700 hover:to-purple-700 dark:hover:from-blue-600 dark:hover:to-purple-600 transition-all shadow-lg hover:shadow-xl hover:scale-105">
<i class="fas fa-download mr-2"></i>
Get Started
</a>
<a href="https://github.com/lukaszraczylo/kubemirror" target="_blank" class="bg-white text-slate-700 px-10 py-4 rounded-xl font-bold text-lg border-2 border-slate-300 hover:border-blue-500 hover:text-blue-600 transition-all shadow-lg hover:shadow-xl hover:scale-105">
<a href="https://github.com/lukaszraczylo/kubemirror" target="_blank" class="bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-10 py-4 rounded-xl font-bold text-lg border-2 border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-400 hover:text-blue-600 dark:hover:text-blue-400 transition-all shadow-lg hover:shadow-xl hover:scale-105 theme-transition">
<i class="fab fa-github mr-2"></i>
GitHub
</a>
@@ -177,55 +192,55 @@
</section>
<!-- The Problem Section -->
<section id="problem" class="py-24 bg-white">
<section id="problem" class="py-24 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl md:text-5xl font-extrabold text-slate-900 mb-6">The Problem</h2>
<p class="text-xl md:text-2xl text-slate-600 max-w-4xl mx-auto">
<div class="text-center mb-12">
<h2 class="text-2xl sm:text-xl sm:text-2xl font-bold text-gray-900 dark:text-white mb-3 sm:mb-4 theme-transition">The Problem</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 max-w-4xl mx-auto theme-transition">
Kubernetes doesn't let you share resources across namespaces. You need the same Secret or ConfigMap in 10 namespaces? You have to duplicate it manually and keep them all in sync.
</p>
</div>
<div class="grid md:grid-cols-3 gap-8 mb-16">
<div class="bg-gradient-to-br from-red-50 to-red-100 border-l-4 border-red-500 p-8 rounded-lg shadow-lg hover-lift">
<div class="flex items-center mb-4">
<i class="fas fa-times-circle text-red-500 text-3xl mr-3"></i>
<h3 class="font-bold text-xl text-slate-900">Manual Duplication</h3>
<div class="bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20 border-l-4 border-red-500 dark:border-red-400 p-6 rounded-lg shadow-modern hover:transform hover:-translate-y-1 transition-all duration-300 theme-transition">
<div class="flex items-center mb-3">
<i class="fas fa-times-circle text-red-500 dark:text-red-400 text-2xl mr-3"></i>
<h3 class="font-semibold text-lg text-gray-900 dark:text-white theme-transition">Manual Duplication</h3>
</div>
<p class="text-slate-700 leading-relaxed">
<p class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed theme-transition">
Copy-paste the same TLS certificate Secret into 20 namespaces. Update it manually in all 20 when it expires.
</p>
</div>
<div class="bg-gradient-to-br from-orange-50 to-orange-100 border-l-4 border-orange-500 p-8 rounded-lg shadow-lg hover-lift">
<div class="flex items-center mb-4">
<i class="fas fa-times-circle text-orange-500 text-3xl mr-3"></i>
<h3 class="font-bold text-xl text-slate-900">Environment Hardcoding</h3>
<div class="bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 border-l-4 border-orange-500 dark:border-orange-400 p-6 rounded-lg shadow-modern hover:transform hover:-translate-y-1 transition-all duration-300 theme-transition">
<div class="flex items-center mb-3">
<i class="fas fa-times-circle text-orange-500 dark:text-orange-400 text-2xl mr-3"></i>
<h3 class="font-semibold text-lg text-gray-900 dark:text-white theme-transition">Environment Hardcoding</h3>
</div>
<p class="text-slate-700 leading-relaxed">
<p class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed theme-transition">
Same ConfigMap but with different API URLs for dev, staging, prod? Create 3 separate versions and maintain them.
</p>
</div>
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 border-l-4 border-yellow-600 p-8 rounded-lg shadow-lg hover-lift">
<div class="flex items-center mb-4">
<i class="fas fa-times-circle text-yellow-600 text-3xl mr-3"></i>
<h3 class="font-bold text-xl text-slate-900">Limited Tools</h3>
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 dark:from-yellow-900/20 dark:to-yellow-800/20 border-l-4 border-yellow-600 dark:border-yellow-500 p-6 rounded-lg shadow-modern hover:transform hover:-translate-y-1 transition-all duration-300 theme-transition">
<div class="flex items-center mb-3">
<i class="fas fa-times-circle text-yellow-600 dark:text-yellow-500 text-2xl mr-3"></i>
<h3 class="font-semibold text-lg text-gray-900 dark:text-white theme-transition">Limited Tools</h3>
</div>
<p class="text-slate-700 leading-relaxed">
<p class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed theme-transition">
Existing tools only support Secrets/ConfigMaps. Want to share Traefik Middleware? Out of luck.
</p>
</div>
</div>
<div class="bg-gradient-to-br from-green-50 to-emerald-100 border-l-4 border-green-500 p-10 rounded-xl shadow-xl hover-lift">
<div class="bg-gradient-to-br from-green-50 to-emerald-100 dark:from-green-900/20 dark:to-emerald-900/20 border-l-4 border-green-500 dark:border-green-400 p-6 rounded-xl shadow-modern hover:transform hover:-translate-y-1 transition-all duration-300 theme-transition">
<div class="flex items-start gap-4">
<i class="fas fa-check-circle text-green-500 text-4xl mt-1"></i>
<i class="fas fa-check-circle text-green-500 dark:text-green-400 text-3xl mt-1"></i>
<div>
<h3 class="font-bold text-2xl md:text-3xl text-slate-900 mb-4">KubeMirror's Solution</h3>
<p class="text-slate-700 text-lg md:text-xl leading-relaxed">
Define your resource once in a source namespace. KubeMirror automatically copies it to target namespaces (specific list, patterns like <code class="bg-white px-3 py-1 rounded font-mono text-purple-600 font-semibold">app-*</code>, or <code class="bg-white px-3 py-1 rounded font-mono text-purple-600 font-semibold">all</code>) and keeps them synchronized.
Transform values per environment (e.g., <code class="bg-white px-3 py-1 rounded font-mono text-purple-600 font-semibold">preprod-*</code> namespaces get preprod API URLs, <code class="bg-white px-3 py-1 rounded font-mono text-purple-600 font-semibold">prod-*</code> get production URLs).
<h3 class="font-bold text-xl text-gray-900 dark:text-white mb-3 theme-transition">KubeMirror's Solution</h3>
<p class="text-gray-700 dark:text-gray-300 text-base leading-relaxed theme-transition">
Define your resource once in a source namespace. KubeMirror automatically copies it to target namespaces (specific list, patterns like <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-sm text-purple-600 dark:text-purple-400 font-semibold theme-transition">app-*</code>, or <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-sm text-purple-600 dark:text-purple-400 font-semibold theme-transition">all</code>) and keeps them synchronized.
Transform values per environment (e.g., <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-sm text-purple-600 dark:text-purple-400 font-semibold theme-transition">preprod-*</code> namespaces get preprod API URLs, <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-sm text-purple-600 dark:text-purple-400 font-semibold theme-transition">prod-*</code> get production URLs).
Works with any Kubernetes resource type.
</p>
</div>
@@ -235,43 +250,43 @@
</section>
<!-- Features Section -->
<section id="features" class="py-24 bg-gradient-to-br from-slate-50 to-blue-50">
<section id="features" class="py-24 bg-gradient-to-br from-slate-50 to-blue-50 dark:from-gray-800 dark:to-gray-900 theme-transition">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-20">
<h2 class="text-4xl md:text-5xl font-extrabold text-slate-900 mb-4">Key Features</h2>
<p class="text-xl text-slate-600">Everything you need for resource mirroring and synchronization</p>
<div class="text-center mb-12">
<h2 class="text-2xl sm:text-xl sm:text-2xl font-bold text-gray-900 dark:text-white mb-3 sm:mb-4 theme-transition">Key Features</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 theme-transition">Everything you need for resource mirroring and synchronization</p>
</div>
<div class="grid md:grid-cols-2 gap-10">
<div class="grid md:grid-cols-2 gap-6">
<!-- Any Resource Type -->
<div class="bg-white p-8 md:p-10 rounded-2xl shadow-xl hover-lift border border-blue-100">
<div class="bg-gradient-to-br from-blue-500 to-purple-600 w-16 h-16 rounded-xl flex items-center justify-center mb-6">
<i class="fas fa-layer-group text-3xl text-white"></i>
<div class="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-modern hover:transform hover:-translate-y-1 transition-all duration-300 border border-blue-100 dark:border-gray-700 theme-transition">
<div class="bg-gradient-to-br from-blue-500 to-purple-600 w-12 h-12 rounded-xl flex items-center justify-center mb-4">
<i class="fas fa-layer-group text-2xl text-white"></i>
</div>
<h3 class="text-2xl md:text-3xl font-bold text-slate-900 mb-4">Mirror Any Resource Type</h3>
<p class="text-slate-600 mb-6 text-lg">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3 theme-transition">Mirror Any Resource Type</h3>
<p class="text-gray-600 dark:text-gray-300 mb-4 text-sm theme-transition">
Not just Secrets and ConfigMaps. Mirror any namespaced Kubernetes resource:
</p>
<ul class="text-slate-700 space-y-3 text-lg">
<li><i class="fas fa-check-circle text-green-500 mr-3"></i>Secrets & ConfigMaps (obviously)</li>
<li><i class="fas fa-check-circle text-green-500 mr-3"></i>Traefik Middleware, IngressRoute</li>
<li><i class="fas fa-check-circle text-green-500 mr-3"></i>Cert-Manager Certificates</li>
<li><i class="fas fa-check-circle text-green-500 mr-3"></i>Any Custom Resource Definition (CRD)</li>
<ul class="text-gray-700 dark:text-gray-300 space-y-2 text-sm theme-transition">
<li><i class="fas fa-check-circle text-green-500 mr-2"></i>Secrets & ConfigMaps (obviously)</li>
<li><i class="fas fa-check-circle text-green-500 mr-2"></i>Traefik Middleware, IngressRoute</li>
<li><i class="fas fa-check-circle text-green-500 mr-2"></i>Cert-Manager Certificates</li>
<li><i class="fas fa-check-circle text-green-500 mr-2"></i>Any Custom Resource Definition (CRD)</li>
</ul>
<div class="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
<p class="text-sm text-slate-600">
<strong class="text-blue-700">How:</strong> KubeMirror discovers all available resource types automatically. No manual configuration needed.
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 theme-transition">
<p class="text-xs text-gray-600 dark:text-gray-300 theme-transition">
<strong class="text-blue-700 dark:text-blue-400">How:</strong> KubeMirror discovers all available resource types automatically. No manual configuration needed.
</p>
</div>
</div>
<!-- Transformation Rules -->
<div class="bg-white p-8 md:p-10 rounded-2xl shadow-xl hover-lift border border-purple-100">
<div class="bg-gradient-to-br from-purple-500 to-pink-600 w-16 h-16 rounded-xl flex items-center justify-center mb-6">
<i class="fas fa-magic text-3xl text-white"></i>
<div class="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-modern hover:transform hover:-translate-y-1 transition-all duration-300 border border-purple-100 dark:border-gray-700 theme-transition">
<div class="bg-gradient-to-br from-purple-500 to-pink-600 w-12 h-12 rounded-xl flex items-center justify-center mb-6">
<i class="fas fa-magic text-2xl text-white"></i>
</div>
<h3 class="text-2xl md:text-3xl font-bold text-slate-900 mb-4">Transform Per Environment</h3>
<p class="text-slate-600 mb-6 text-lg">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4 theme-transition">Transform Per Environment</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 text-sm theme-transition">
Change values automatically based on target namespace:
</p>
<div class="code-block text-gray-100 p-5 rounded-xl font-mono text-xs md:text-sm overflow-x-auto mb-4 shadow-lg">
@@ -285,65 +300,65 @@
<span class="text-yellow-400">value:</span> <span class="text-blue-400">"https://api.com"</span>
<span class="text-yellow-400">namespacePattern:</span> <span class="text-blue-400">"prod-*"</span></pre>
</div>
<div class="p-4 bg-purple-50 rounded-lg border border-purple-200">
<p class="text-sm text-slate-600">
<strong class="text-purple-700">Why:</strong> One source ConfigMap, different values per environment. No manual maintenance.
<div class="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800 theme-transition">
<p class="text-sm text-gray-600 dark:text-gray-300 theme-transition">
<strong class="text-purple-700 dark:text-purple-400">Why:</strong> One source ConfigMap, different values per environment. No manual maintenance.
</p>
</div>
</div>
<!-- Automatic Sync -->
<div class="bg-white p-8 md:p-10 rounded-2xl shadow-xl hover-lift border border-green-100">
<div class="bg-gradient-to-br from-green-500 to-teal-600 w-16 h-16 rounded-xl flex items-center justify-center mb-6">
<i class="fas fa-sync-alt text-3xl text-white"></i>
<div class="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-modern hover:transform hover:-translate-y-1 transition-all duration-300 border border-green-100 dark:border-gray-700 theme-transition">
<div class="bg-gradient-to-br from-green-500 to-teal-600 w-12 h-12 rounded-xl flex items-center justify-center mb-6">
<i class="fas fa-sync-alt text-2xl text-white"></i>
</div>
<h3 class="text-2xl md:text-3xl font-bold text-slate-900 mb-4">Automatic Synchronization</h3>
<p class="text-slate-600 mb-6 text-lg">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4 theme-transition">Automatic Synchronization</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 text-sm theme-transition">
Update the source once. All copies update automatically:
</p>
<ul class="text-slate-700 space-y-3 text-lg">
<ul class="text-gray-700 dark:text-gray-300 space-y-3 text-sm theme-transition">
<li><i class="fas fa-arrow-right text-blue-500 mr-3"></i>Update source Secret → All 50 copies update</li>
<li><i class="fas fa-arrow-right text-blue-500 mr-3"></i>Delete source → All copies get deleted</li>
<li><i class="fas fa-arrow-right text-blue-500 mr-3"></i>Someone deletes a copy → Recreated automatically</li>
<li><i class="fas fa-arrow-right text-blue-500 mr-3"></i>New namespace created → Copy appears automatically</li>
</ul>
<div class="mt-6 p-4 bg-green-50 rounded-lg border border-green-200">
<p class="text-sm text-slate-600">
<strong class="text-green-700">How:</strong> Uses SHA256 content hashing + Kubernetes generation tracking. Only updates when content actually changes.
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800 theme-transition">
<p class="text-sm text-gray-600 dark:text-gray-300 theme-transition">
<strong class="text-green-700 dark:text-green-400">How:</strong> Uses SHA256 content hashing + Kubernetes generation tracking. Only updates when content actually changes.
</p>
</div>
</div>
<!-- Smart Targeting -->
<div class="bg-white p-8 md:p-10 rounded-2xl shadow-xl hover-lift border border-orange-100">
<div class="bg-gradient-to-br from-orange-500 to-red-600 w-16 h-16 rounded-xl flex items-center justify-center mb-6">
<i class="fas fa-bullseye text-3xl text-white"></i>
<div class="bg-white dark:bg-gray-800 p-6 rounded-2xl shadow-modern hover:transform hover:-translate-y-1 transition-all duration-300 border border-orange-100 dark:border-gray-700 theme-transition">
<div class="bg-gradient-to-br from-orange-500 to-red-600 w-12 h-12 rounded-xl flex items-center justify-center mb-6">
<i class="fas fa-bullseye text-2xl text-white"></i>
</div>
<h3 class="text-2xl md:text-3xl font-bold text-slate-900 mb-4">Flexible Targeting</h3>
<p class="text-slate-600 mb-6 text-lg">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-4 theme-transition">Flexible Targeting</h3>
<p class="text-gray-600 dark:text-gray-300 mb-6 text-sm theme-transition">
Choose which namespaces receive the copy:
</p>
<div class="space-y-4 text-slate-700 text-base md:text-lg">
<div class="space-y-4 text-gray-700 dark:text-gray-300 text-base md:text-sm theme-transition">
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<code class="bg-slate-100 px-4 py-2 rounded-lg font-mono text-purple-700 font-semibold text-sm">namespace-1,namespace-2</code>
<code class="bg-gray-100 dark:bg-gray-700 px-4 py-2 rounded-lg font-mono text-purple-700 dark:text-purple-400 font-semibold text-sm theme-transition">namespace-1,namespace-2</code>
<span>Specific namespaces</span>
</div>
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<code class="bg-slate-100 px-4 py-2 rounded-lg font-mono text-purple-700 font-semibold text-sm">app-*,prod-*</code>
<code class="bg-gray-100 dark:bg-gray-700 px-4 py-2 rounded-lg font-mono text-purple-700 dark:text-purple-400 font-semibold text-sm theme-transition">app-*,prod-*</code>
<span>Pattern matching</span>
</div>
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<code class="bg-slate-100 px-4 py-2 rounded-lg font-mono text-purple-700 font-semibold text-sm">all</code>
<code class="bg-gray-100 dark:bg-gray-700 px-4 py-2 rounded-lg font-mono text-purple-700 dark:text-purple-400 font-semibold text-sm theme-transition">all</code>
<span>All namespaces (no labels required)</span>
</div>
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<code class="bg-slate-100 px-4 py-2 rounded-lg font-mono text-purple-700 font-semibold text-sm">all-labeled</code>
<code class="bg-gray-100 dark:bg-gray-700 px-4 py-2 rounded-lg font-mono text-purple-700 dark:text-purple-400 font-semibold text-sm theme-transition">all-labeled</code>
<span>Only namespaces with opt-in label</span>
</div>
</div>
<div class="mt-6 p-4 bg-orange-50 rounded-lg border border-orange-200">
<p class="text-sm text-slate-600">
<strong class="text-orange-700">Safety:</strong> Source namespace never receives a copy. Max 100 targets per resource (configurable).
<div class="mt-6 p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800 theme-transition">
<p class="text-sm text-gray-600 dark:text-gray-300 theme-transition">
<strong class="text-orange-700 dark:text-orange-400">Safety:</strong> Source namespace never receives a copy. Max 100 targets per resource (configurable).
</p>
</div>
</div>
@@ -352,26 +367,26 @@
</section>
<!-- Examples Section -->
<section id="examples" class="py-24 bg-white">
<section id="examples" class="py-24 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-20">
<h2 class="text-4xl md:text-5xl font-extrabold text-slate-900 mb-4">Real-World Examples</h2>
<p class="text-xl text-slate-600">See how easy it is to get started with KubeMirror</p>
<div class="text-center mb-12">
<h2 class="text-2xl sm:text-xl sm:text-2xl font-extrabold text-gray-900 dark:text-white mb-4 theme-transition">Real-World Examples</h2>
<p class="text-xl text-gray-600 dark:text-gray-300 theme-transition">See how easy it is to get started with KubeMirror</p>
</div>
<div class="space-y-12">
<!-- Example 1: Basic Secret -->
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 p-8 md:p-10 rounded-2xl shadow-xl border border-blue-200 hover-lift">
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 p-6 rounded-2xl shadow-modern border border-blue-200 dark:border-blue-800 hover:transform hover:-translate-y-1 transition-all duration-300 theme-transition">
<div class="flex flex-col sm:flex-row items-start gap-6 mb-6">
<div class="bg-blue-600 w-14 h-14 rounded-xl flex items-center justify-center flex-shrink-0">
<div class="bg-blue-600 dark:bg-blue-700 w-14 h-14 rounded-xl flex items-center justify-center flex-shrink-0">
<span class="text-white font-bold text-2xl">1</span>
</div>
<div>
<h3 class="text-2xl md:text-3xl font-bold text-slate-900 mb-3">
<i class="fas fa-lock text-blue-600 mr-3"></i>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3 theme-transition">
<i class="fas fa-lock text-blue-600 dark:text-blue-400 mr-3"></i>
Basic: Mirror a TLS Secret
</h3>
<p class="text-slate-600 text-lg">Share your TLS certificate across multiple application namespaces</p>
<p class="text-gray-600 dark:text-gray-300 text-sm theme-transition">Share your TLS certificate across multiple application namespaces</p>
</div>
</div>
<div class="code-block text-gray-100 p-4 md:p-6 rounded-xl font-mono text-xs md:text-sm overflow-x-auto shadow-lg">
@@ -393,17 +408,17 @@
</div>
<!-- Example 2: Pattern Matching -->
<div class="bg-gradient-to-br from-purple-50 to-pink-50 p-8 md:p-10 rounded-2xl shadow-xl border border-purple-200 hover-lift">
<div class="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 p-6 rounded-2xl shadow-modern border border-purple-200 dark:border-purple-800 hover:transform hover:-translate-y-1 transition-all duration-300 theme-transition">
<div class="flex flex-col sm:flex-row items-start gap-6 mb-6">
<div class="bg-purple-600 w-14 h-14 rounded-xl flex items-center justify-center flex-shrink-0">
<div class="bg-purple-600 dark:bg-purple-700 w-14 h-14 rounded-xl flex items-center justify-center flex-shrink-0">
<span class="text-white font-bold text-2xl">2</span>
</div>
<div>
<h3 class="text-2xl md:text-3xl font-bold text-slate-900 mb-3">
<i class="fas fa-asterisk text-purple-600 mr-3"></i>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3 theme-transition">
<i class="fas fa-asterisk text-purple-600 dark:text-purple-400 mr-3"></i>
Pattern Matching: Mirror to All App Namespaces
</h3>
<p class="text-slate-600 text-lg">Use wildcards to mirror to all namespaces matching a pattern</p>
<p class="text-gray-600 dark:text-gray-300 text-sm theme-transition">Use wildcards to mirror to all namespaces matching a pattern</p>
</div>
</div>
<div class="code-block text-gray-100 p-4 md:p-6 rounded-xl font-mono text-xs md:text-sm overflow-x-auto shadow-lg">
@@ -422,26 +437,26 @@
<span class="text-yellow-400">log_level:</span> <span class="text-purple-400">"info"</span>
<span class="text-yellow-400">api_url:</span> <span class="text-purple-400">"https://api.example.com"</span></pre>
</div>
<div class="mt-6 p-5 bg-purple-100 rounded-lg border border-purple-300">
<p class="text-slate-700 text-base md:text-lg">
<i class="fas fa-info-circle text-purple-600 mr-2"></i>
<strong>Result:</strong> This ConfigMap will be automatically copied to <code class="bg-white px-2 py-1 rounded font-mono text-purple-700 text-sm">app-frontend</code>, <code class="bg-white px-2 py-1 rounded font-mono text-purple-700 text-sm">app-backend</code>, <code class="bg-white px-2 py-1 rounded font-mono text-purple-700 text-sm">app-worker</code>, and any other namespace starting with "app-"
<div class="mt-6 p-5 bg-purple-100 dark:bg-purple-900/40 rounded-lg border border-purple-300 dark:border-purple-700 theme-transition">
<p class="text-gray-700 dark:text-gray-300 text-base md:text-sm theme-transition">
<i class="fas fa-info-circle text-purple-600 dark:text-purple-400 mr-2"></i>
<strong>Result:</strong> This ConfigMap will be automatically copied to <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-purple-700 dark:text-purple-400 text-sm theme-transition">app-frontend</code>, <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-purple-700 dark:text-purple-400 text-sm theme-transition">app-backend</code>, <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-purple-700 dark:text-purple-400 text-sm theme-transition">app-worker</code>, and any other namespace starting with "app-"
</p>
</div>
</div>
<!-- Example 3: Custom Resource (Traefik) -->
<div class="bg-gradient-to-br from-green-50 to-teal-50 p-8 md:p-10 rounded-2xl shadow-xl border border-green-200 hover-lift">
<div class="bg-gradient-to-br from-green-50 to-teal-50 dark:from-green-900/20 dark:to-teal-900/20 p-6 rounded-2xl shadow-modern border border-green-200 dark:border-green-800 hover:transform hover:-translate-y-1 transition-all duration-300 theme-transition">
<div class="flex flex-col sm:flex-row items-start gap-6 mb-6">
<div class="bg-green-600 w-14 h-14 rounded-xl flex items-center justify-center flex-shrink-0">
<div class="bg-green-600 dark:bg-green-700 w-14 h-14 rounded-xl flex items-center justify-center flex-shrink-0">
<span class="text-white font-bold text-2xl">3</span>
</div>
<div>
<h3 class="text-2xl md:text-3xl font-bold text-slate-900 mb-3">
<i class="fas fa-cubes text-green-600 mr-3"></i>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3 theme-transition">
<i class="fas fa-cubes text-green-600 dark:text-green-400 mr-3"></i>
Custom Resource: Share Traefik Middleware
</h3>
<p class="text-slate-600 text-lg">Mirror any CRD like Traefik Middleware across your cluster</p>
<p class="text-gray-600 dark:text-gray-300 text-sm theme-transition">Mirror any CRD like Traefik Middleware across your cluster</p>
</div>
</div>
<div class="code-block text-gray-100 p-4 md:p-6 rounded-xl font-mono text-xs md:text-sm overflow-x-auto shadow-lg">
@@ -461,9 +476,9 @@
<span class="text-yellow-400">excludedContentTypes:</span>
- text/event-stream</pre>
</div>
<div class="mt-6 p-5 bg-green-100 rounded-lg border border-green-300">
<p class="text-slate-700 text-base md:text-lg">
<i class="fas fa-lightbulb text-green-600 mr-2"></i>
<div class="mt-6 p-5 bg-green-100 dark:bg-green-900/40 rounded-lg border border-green-300 dark:border-green-700 theme-transition">
<p class="text-gray-700 dark:text-gray-300 text-base md:text-sm theme-transition">
<i class="fas fa-lightbulb text-green-600 dark:text-green-400 mr-2"></i>
<strong>Works with any CRD:</strong> Cert-Manager Certificates, Gateway API resources, or your own custom resources!
</p>
</div>
@@ -473,89 +488,91 @@
</section>
<!-- Comparison Section -->
<section id="comparison" class="py-24 bg-gradient-to-br from-slate-50 to-blue-50">
<section id="comparison" class="py-24 bg-gradient-to-br from-slate-50 to-blue-50 dark:from-gray-800 dark:to-gray-900 theme-transition">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-4xl md:text-5xl font-extrabold text-slate-900 mb-6">How KubeMirror Compares</h2>
<p class="text-xl md:text-2xl text-slate-600">We built KubeMirror to replace <a href="https://github.com/emberstack/kubernetes-reflector" class="text-blue-600 hover:underline font-semibold" target="_blank">emberstack/reflector</a></p>
<h2 class="text-2xl sm:text-xl sm:text-2xl font-extrabold text-gray-900 dark:text-white mb-6 theme-transition">How KubeMirror Compares</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 theme-transition">We built KubeMirror to replace <a href="https://github.com/emberstack/kubernetes-reflector" class="text-blue-600 dark:text-blue-400 hover:underline font-semibold" target="_blank">emberstack/reflector</a></p>
</div>
<div class="overflow-x-auto rounded-2xl shadow-2xl">
<table class="w-full bg-white">
<thead class="bg-gradient-to-r from-slate-800 to-slate-900 text-white">
<tr>
<th class="px-6 md:px-8 py-6 text-left font-bold text-base md:text-lg">Capability</th>
<th class="px-6 md:px-8 py-6 text-center font-bold text-base md:text-lg">KubeMirror</th>
<th class="px-6 md:px-8 py-6 text-center font-bold text-base md:text-lg">Reflector</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
<tr class="hover:bg-blue-50 transition-colors">
<td class="px-6 md:px-8 py-6">
<div class="font-semibold text-base md:text-lg text-slate-900">Supported Resources</div>
<div class="text-sm text-slate-600 mt-1">What resource types can be mirrored</div>
</td>
<td class="px-6 md:px-8 py-6 text-center">
<div><i class="fas fa-check-circle text-green-500 text-2xl md:text-3xl"></i></div>
<div class="text-xs md:text-sm font-semibold text-green-700 mt-2">Secrets, ConfigMaps, CRDs, etc.</div>
</td>
<td class="px-6 md:px-8 py-6 text-center">
<div><i class="fas fa-minus-circle text-yellow-500 text-2xl md:text-3xl"></i></div>
<div class="text-xs md:text-sm font-semibold text-yellow-700 mt-2">Secrets, ConfigMaps only</div>
</td>
</tr>
<tr class="hover:bg-blue-50 transition-colors bg-slate-50">
<td class="px-6 md:px-8 py-6">
<div class="font-semibold text-base md:text-lg text-slate-900">Auto-Discovery</div>
<div class="text-sm text-slate-600 mt-1">Finds all resource types automatically</div>
</td>
<td class="px-6 md:px-8 py-6 text-center">
<div><i class="fas fa-check-circle text-green-500 text-2xl md:text-3xl"></i></div>
<div class="text-xs md:text-sm font-semibold text-green-700 mt-2">Yes</div>
</td>
<td class="px-6 md:px-8 py-6 text-center">
<div><i class="fas fa-times-circle text-red-500 text-2xl md:text-3xl"></i></div>
<div class="text-xs md:text-sm font-semibold text-red-700 mt-2">Hardcoded</div>
</td>
</tr>
<tr class="hover:bg-blue-50 transition-colors">
<td class="px-6 md:px-8 py-6">
<div class="font-semibold text-base md:text-lg text-slate-900">Value Transformation</div>
<div class="text-sm text-slate-600 mt-1">Change values per target namespace</div>
</td>
<td class="px-6 md:px-8 py-6 text-center">
<div><i class="fas fa-check-circle text-green-500 text-2xl md:text-3xl"></i></div>
<div class="text-xs md:text-sm font-semibold text-green-700 mt-2">Full support</div>
</td>
<td class="px-6 md:px-8 py-6 text-center">
<div><i class="fas fa-times-circle text-red-500 text-2xl md:text-3xl"></i></div>
<div class="text-xs md:text-sm font-semibold text-red-700 mt-2">Not available</div>
</td>
</tr>
<tr class="hover:bg-blue-50 transition-colors bg-slate-50">
<td class="px-6 md:px-8 py-6">
<div class="font-semibold text-base md:text-lg text-slate-900">Active Development</div>
<div class="text-sm text-slate-600 mt-1">Regular updates and bug fixes</div>
</td>
<td class="px-6 md:px-8 py-6 text-center">
<div><i class="fas fa-check-circle text-green-500 text-2xl md:text-3xl"></i></div>
<div class="text-xs md:text-sm font-semibold text-green-700 mt-2">Active</div>
</td>
<td class="px-6 md:px-8 py-6 text-center">
<div><i class="fas fa-check-circle text-green-500 text-2xl md:text-3xl"></i></div>
<div class="text-xs md:text-sm font-semibold text-green-700 mt-2">Recently resumed (2025)</div>
</td>
</tr>
</tbody>
</table>
<div class="glass rounded-xl overflow-hidden shadow-modern">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
<tr>
<th class="px-4 py-3 text-left font-semibold">Feature</th>
<th class="px-4 py-3 text-center font-semibold">KubeMirror</th>
<th class="px-4 py-3 text-center font-semibold">Reflector</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white theme-transition">Supported Resources</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">What resource types can be mirrored</div>
</td>
<td class="px-4 py-3 text-center">
<span class="text-green-500 text-xl"></span>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">All CRDs</div>
</td>
<td class="px-4 py-3 text-center">
<span class="text-yellow-500 text-xl"></span>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">Secrets, ConfigMaps only</div>
</td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white theme-transition">Auto-Discovery</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">Finds all resource types automatically</div>
</td>
<td class="px-4 py-3 text-center">
<span class="text-green-500 text-xl"></span>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">Yes</div>
</td>
<td class="px-4 py-3 text-center">
<span class="text-red-500 text-xl"></span>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">Hardcoded</div>
</td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white theme-transition">Value Transformation</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">Change values per target namespace</div>
</td>
<td class="px-4 py-3 text-center">
<span class="text-green-500 text-xl"></span>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">Full support</div>
</td>
<td class="px-4 py-3 text-center">
<span class="text-red-500 text-xl"></span>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">Not available</div>
</td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white theme-transition">Active Development</div>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">Regular updates and bug fixes</div>
</td>
<td class="px-4 py-3 text-center">
<span class="text-green-500 text-xl"></span>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">Active</div>
</td>
<td class="px-4 py-3 text-center">
<span class="text-green-500 text-xl"></span>
<div class="text-xs text-gray-600 dark:text-gray-400 mt-1 theme-transition">Recently resumed (2025)</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mt-16 bg-gradient-to-br from-blue-50 to-indigo-100 border-l-4 border-blue-600 p-8 rounded-xl shadow-xl">
<div class="mt-16 bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-900/20 dark:to-indigo-900/20 border-l-4 border-blue-600 dark:border-blue-400 p-8 rounded-xl shadow-modern theme-transition">
<div class="flex flex-col sm:flex-row items-start gap-6">
<i class="fas fa-info-circle text-blue-600 text-3xl md:text-4xl mt-1"></i>
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 text-xl sm:text-2xl mt-1"></i>
<div>
<h4 class="text-xl md:text-2xl font-bold text-slate-900 mb-4">Why We Built KubeMirror</h4>
<p class="text-slate-700 text-base md:text-lg leading-relaxed">
<h4 class="text-base sm:text-lg font-bold text-gray-900 dark:text-white mb-4 theme-transition">Why We Built KubeMirror</h4>
<p class="text-gray-700 dark:text-gray-300 text-sm leading-relaxed theme-transition">
We needed to share Traefik Middleware across 200+ namespaces with environment-specific configurations.
Reflector couldn't do it (Secrets/ConfigMaps only, no transformations). So we built KubeMirror with modern
Kubernetes best practices and all the features we wished Reflector had.
@@ -567,21 +584,21 @@
</section>
<!-- Installation Section -->
<section id="installation" class="py-24 bg-white">
<section id="installation" class="py-24 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-20">
<h2 class="text-4xl md:text-5xl font-extrabold text-slate-900 mb-4">Installation</h2>
<p class="text-xl md:text-2xl text-slate-600">Get started in under 2 minutes</p>
<div class="text-center mb-12">
<h2 class="text-2xl sm:text-xl sm:text-2xl font-extrabold text-gray-900 dark:text-white mb-4 theme-transition">Installation</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 theme-transition">Get started in under 2 minutes</p>
</div>
<div class="grid md:grid-cols-2 gap-10 mb-16">
<div class="grid md:grid-cols-2 gap-6 mb-16">
<!-- Helm Installation -->
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 p-8 md:p-10 rounded-2xl shadow-xl border border-blue-200 hover-lift">
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 p-6 rounded-2xl shadow-modern border border-blue-200 dark:border-blue-800 hover:transform hover:-translate-y-1 transition-all duration-300 theme-transition">
<div class="flex items-center mb-6">
<div class="bg-blue-600 w-14 h-14 rounded-xl flex items-center justify-center mr-4">
<div class="bg-blue-600 dark:bg-blue-700 w-14 h-14 rounded-xl flex items-center justify-center mr-4">
<i class="fas fa-ship text-2xl text-white"></i>
</div>
<h3 class="text-2xl md:text-3xl font-bold text-slate-900">Helm <span class="text-blue-600">(Recommended)</span></h3>
<h3 class="text-xl font-bold text-gray-900 dark:text-white theme-transition">Helm <span class="text-blue-600 dark:text-blue-400">(Recommended)</span></h3>
</div>
<div class="code-block text-gray-100 p-4 md:p-6 rounded-xl font-mono text-xs md:text-sm overflow-x-auto shadow-lg">
<pre><span class="text-green-400">helm repo add kubemirror \</span>
@@ -595,12 +612,12 @@
</div>
<!-- kubectl Installation -->
<div class="bg-gradient-to-br from-purple-50 to-pink-50 p-8 md:p-10 rounded-2xl shadow-xl border border-purple-200 hover-lift">
<div class="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 p-6 rounded-2xl shadow-modern border border-purple-200 dark:border-purple-800 hover:transform hover:-translate-y-1 transition-all duration-300 theme-transition">
<div class="flex items-center mb-6">
<div class="bg-purple-600 w-14 h-14 rounded-xl flex items-center justify-center mr-4">
<div class="bg-purple-600 dark:bg-purple-700 w-14 h-14 rounded-xl flex items-center justify-center mr-4">
<i class="fas fa-terminal text-2xl text-white"></i>
</div>
<h3 class="text-2xl md:text-3xl font-bold text-slate-900">kubectl</h3>
<h3 class="text-xl font-bold text-gray-900 dark:text-white theme-transition">kubectl</h3>
</div>
<div class="code-block text-gray-100 p-4 md:p-6 rounded-xl font-mono text-xs md:text-sm overflow-x-auto shadow-lg">
<pre><span class="text-green-400">kubectl apply -k \</span>
@@ -614,17 +631,17 @@
</div>
<!-- Quick Start Example -->
<div class="bg-gradient-to-br from-green-50 to-teal-50 p-8 md:p-12 rounded-2xl shadow-2xl border border-green-200">
<h3 class="text-3xl md:text-4xl font-bold text-slate-900 mb-8 text-center">
<i class="fas fa-rocket text-green-600 mr-3"></i>
<div class="bg-gradient-to-br from-green-50 to-teal-50 dark:from-green-900/20 dark:to-teal-900/20 p-6 md:p-8 rounded-2xl shadow-2xl border border-green-200 dark:border-green-800 theme-transition">
<h3 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white mb-8 text-center theme-transition">
<i class="fas fa-rocket text-green-600 dark:text-green-400 mr-3"></i>
Quick Start: Mirror a Secret in 30 Seconds
</h3>
<div class="grid md:grid-cols-2 gap-10">
<div class="grid md:grid-cols-2 gap-6">
<div>
<div class="flex items-center gap-3 mb-6">
<div class="bg-green-600 w-10 h-10 rounded-full flex items-center justify-center text-white font-bold">1</div>
<h4 class="font-bold text-xl md:text-2xl text-slate-900">Create your source Secret</h4>
<div class="bg-green-600 dark:bg-green-700 w-10 h-10 rounded-full flex items-center justify-center text-white font-bold">1</div>
<h4 class="font-bold text-base sm:text-lg text-gray-900 dark:text-white theme-transition">Create your source Secret</h4>
</div>
<div class="code-block text-gray-100 p-4 md:p-6 rounded-xl font-mono text-xs md:text-sm overflow-x-auto shadow-lg">
<pre><span class="text-blue-400">apiVersion:</span> v1
@@ -646,16 +663,16 @@
<div>
<div class="flex items-center gap-3 mb-6">
<div class="bg-green-600 w-10 h-10 rounded-full flex items-center justify-center text-white font-bold">2</div>
<h4 class="font-bold text-xl md:text-2xl text-slate-900">That's it!</h4>
<div class="bg-green-600 dark:bg-green-700 w-10 h-10 rounded-full flex items-center justify-center text-white font-bold">2</div>
<h4 class="font-bold text-base sm:text-lg text-gray-900 dark:text-white theme-transition">That's it!</h4>
</div>
<p class="text-slate-700 mb-6 text-base md:text-lg">
<p class="text-gray-700 dark:text-gray-300 mb-6 text-base md:text-sm theme-transition">
KubeMirror automatically:
</p>
<ul class="text-slate-700 space-y-4 text-base md:text-lg">
<ul class="text-gray-700 dark:text-gray-300 space-y-4 text-base md:text-sm theme-transition">
<li class="flex items-start gap-3">
<i class="fas fa-check-circle text-green-500 text-xl mt-1"></i>
<span>Creates copies in <code class="bg-white px-2 py-1 rounded font-mono text-green-700 text-sm">app-1</code> and <code class="bg-white px-2 py-1 rounded font-mono text-green-700 text-sm">app-2</code></span>
<span>Creates copies in <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-green-700 dark:text-green-400 text-sm theme-transition">app-1</code> and <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-green-700 dark:text-green-400 text-sm theme-transition">app-2</code></span>
</li>
<li class="flex items-start gap-3">
<i class="fas fa-check-circle text-green-500 text-xl mt-1"></i>
@@ -670,10 +687,10 @@
<span>Cleans up all copies when you delete the source</span>
</li>
</ul>
<div class="mt-8 p-5 bg-green-100 rounded-lg border border-green-300">
<p class="text-sm text-slate-700">
<strong class="text-green-800">Required:</strong> Both the label <code class="bg-white px-2 py-1 rounded font-mono text-green-700 text-xs">kubemirror.raczylo.com/enabled</code>
and annotation <code class="bg-white px-2 py-1 rounded font-mono text-green-700 text-xs">kubemirror.raczylo.com/sync</code> are needed.
<div class="mt-8 p-5 bg-green-100 dark:bg-green-900/40 rounded-lg border border-green-300 dark:border-green-700 theme-transition">
<p class="text-sm text-gray-700 dark:text-gray-300 theme-transition">
<strong class="text-green-800 dark:text-green-400">Required:</strong> Both the label <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-green-700 dark:text-green-400 text-xs theme-transition">kubemirror.raczylo.com/enabled</code>
and annotation <code class="bg-white dark:bg-gray-800 px-2 py-1 rounded font-mono text-green-700 dark:text-green-400 text-xs theme-transition">kubemirror.raczylo.com/sync</code> are needed.
</p>
</div>
</div>
@@ -683,7 +700,7 @@
</section>
<!-- Footer -->
<footer class="bg-gradient-to-br from-slate-900 to-slate-800 text-gray-300 py-16">
<footer class="bg-gradient-to-br from-gray-900 to-gray-800 dark:from-black dark:to-gray-900 text-gray-300 dark:text-gray-400 py-16 theme-transition">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid md:grid-cols-3 gap-12">
<div>
@@ -693,12 +710,12 @@
</div>
<span class="text-2xl font-bold text-white">KubeMirror</span>
</div>
<p class="text-gray-400 text-base md:text-lg leading-relaxed">
<p class="text-gray-400 dark:text-gray-500 text-sm leading-relaxed theme-transition">
Copy Kubernetes resources across namespaces. Modern replacement for Reflector.
</p>
</div>
<div>
<h4 class="text-lg md:text-xl font-bold text-white mb-6">Links</h4>
<h4 class="text-base font-bold text-white mb-6">Links</h4>
<ul class="space-y-3 text-base md:text-lg">
<li><a href="https://github.com/lukaszraczylo/kubemirror" target="_blank" class="hover:text-white transition-colors"><i class="fab fa-github mr-2"></i>GitHub</a></li>
<li><a href="https://github.com/lukaszraczylo/kubemirror/issues" target="_blank" class="hover:text-white transition-colors"><i class="fas fa-bug mr-2"></i>Report Issue</a></li>
@@ -706,26 +723,25 @@
</ul>
</div>
<div>
<h4 class="text-lg md:text-xl font-bold text-white mb-6">License</h4>
<p class="text-gray-400 text-base md:text-lg">MIT License</p>
<p class="text-gray-400 mt-4 text-base md:text-lg">© 2024 Lukasz Raczylo</p>
<h4 class="text-lg font-bold text-white mb-6">License</h4>
<p class="text-gray-400 dark:text-gray-500 text-sm theme-transition">MIT License</p>
<p class="text-gray-400 dark:text-gray-500 mt-4 text-sm theme-transition">© 2025 Lukasz Raczylo</p>
</div>
</div>
</div>
</footer>
<!-- Back to Top Button -->
<button id="backToTop" class="fixed bottom-8 right-8 bg-gradient-to-r from-blue-600 to-purple-600 text-white p-4 rounded-full shadow-2xl opacity-0 pointer-events-none transition-opacity duration-300 hover:scale-110 z-50">
<button id="backToTop" class="fixed bottom-8 right-8 bg-gradient-to-r from-blue-600 to-purple-600 dark:from-blue-500 dark:to-purple-500 text-white p-4 rounded-full shadow-2xl opacity-0 pointer-events-none transition-opacity duration-300 hover:scale-110 z-50">
<i class="fas fa-arrow-up text-xl"></i>
</button>
<script>
// Scroll progress bar
window.addEventListener('scroll', () => {
const winScroll = document.body.scrollTop || document.documentElement.scrollTop;
const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
const scrolled = (winScroll / height) * 100;
document.getElementById('progressBar').style.width = scrolled + '%';
// Theme toggle
const themeToggle = document.getElementById('theme-toggle');
themeToggle.addEventListener('click', () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.theme = isDark ? 'dark' : 'light';
});
// Mobile menu toggle
@@ -733,20 +749,20 @@
const mobileMenu = document.getElementById('mobileMenu');
mobileMenuBtn.addEventListener('click', () => {
mobileMenu.classList.toggle('active');
mobileMenu.classList.toggle('translate-x-full');
});
// Close mobile menu when clicking a link
document.querySelectorAll('#mobileMenu a').forEach(link => {
link.addEventListener('click', () => {
mobileMenu.classList.remove('active');
mobileMenu.classList.add('translate-x-full');
});
});
// Close mobile menu when clicking outside
document.addEventListener('click', (e) => {
if (!mobileMenu.contains(e.target) && !mobileMenuBtn.contains(e.target)) {
mobileMenu.classList.remove('active');
mobileMenu.classList.add('translate-x-full');
}
});
@@ -777,6 +793,29 @@
}
});
});
// Rotating word animation (flip board style)
const words = ['Copy', 'Mirror', 'Clone', 'Render'];
let currentWordIndex = 0;
const rotatingWordElement = document.getElementById('rotatingWord');
function rotateWord() {
rotatingWordElement.classList.remove('word-flip');
// Trigger reflow to restart animation
void rotatingWordElement.offsetWidth;
rotatingWordElement.classList.add('word-flip');
// Change word at the midpoint of the flip (when it's perpendicular)
setTimeout(() => {
currentWordIndex = (currentWordIndex + 1) % words.length;
rotatingWordElement.textContent = words[currentWordIndex];
}, 300); // Half of the 600ms animation duration
}
// Rotate word every 3 seconds
setInterval(rotateWord, 3000);
</script>
</body>
</html>
+22 -2
View File
@@ -70,6 +70,8 @@ main() {
--max-targets=100 \
--worker-threads=5 \
--verify-source-freshness=true \
--lazy-watcher-init=true \
--watcher-scan-interval=500ms \
>"$KUBEMIRROR_LOG" 2>&1 &
KUBEMIRROR_PID=$!
@@ -134,6 +136,24 @@ main() {
fi
echo ""
# # Lazy Watcher Initialization Test
# echo "======================================"
# echo "Running Lazy Watcher Initialization Test"
# echo "======================================"
# echo "This will test:"
# echo " - Initial state with minimal controllers registered"
# echo " - Dynamic controller registration on resource creation"
# echo " - Memory efficiency of lazy initialization"
# echo ""
# if bash "$SCRIPT_DIR/test-lazy-watcher-init.sh"; then
# log_success "Lazy Watcher Test PASSED"
# else
# log_fail "Lazy Watcher Test FAILED"
# test_results=1
# fi
# echo ""
# Step 6: Final summary
echo "======================================"
echo "E2E Test Run Complete"
@@ -146,8 +166,8 @@ main() {
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"
log_info "Last 10 lines of controller log:"
tail -10 "$KUBEMIRROR_LOG"
return 1
fi
}
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env bash
# E2E Test: Lazy Watcher Initialization
#
# Tests that kube-mirror only creates watchers for resource types that have
# resources marked for mirroring, reducing memory usage dramatically.
#
# Scenario:
# 1. Assumes controller is already running with --lazy-watcher-init=true
# 2. Verify initial controller registration count
# 3. Create a Secret with the enabled label
# 4. Wait for scan interval (500ms in e2e tests)
# 5. Verify controller was registered for Secrets
# 6. Create a ConfigMap with the enabled label
# 7. Verify controller was registered for ConfigMaps
# 8. Verify mirroring works correctly
#
# This test expects the controller to be running with:
# --lazy-watcher-init=true
# --watcher-scan-interval=500ms
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/common.sh"
# Test configuration
TEST_NAME="lazy-watcher-init"
SOURCE_NS="kubemirror-e2e-lazy-source"
TARGET_NS="kubemirror-e2e-lazy-target"
KUBEMIRROR_LOG="${KUBEMIRROR_LOG:-/tmp/kubemirror-e2e-test.log}"
# Helper functions
create_namespace() {
local ns="$1"
kubectl create namespace "${ns}" 2>/dev/null || true
echo "✓ Created namespace: ${ns}"
}
delete_namespace() {
local ns="$1"
kubectl delete namespace "${ns}" --wait=false 2>/dev/null || true
echo "✓ Deleted namespace: ${ns}"
}
assert_secret_exists() {
local ns="$1"
local name="$2"
kubectl get secret -n "${ns}" "${name}" &>/dev/null || {
echo "❌ Secret ${name} not found in namespace ${ns}"
return 1
}
echo "✓ Secret ${name} exists in namespace ${ns}"
}
assert_secret_data() {
local ns="$1"
local name="$2"
local key="$3"
local expected="$4"
local actual=$(kubectl get secret -n "${ns}" "${name}" -o jsonpath="{.data.${key}}" | base64 -d)
if [[ "${actual}" != "${expected}" ]]; then
echo "❌ Secret ${name} key ${key}: expected '${expected}', got '${actual}'"
return 1
fi
echo "✓ Secret ${name} key ${key} matches expected value"
}
assert_configmap_exists() {
local ns="$1"
local name="$2"
kubectl get configmap -n "${ns}" "${name}" &>/dev/null || {
echo "❌ ConfigMap ${name} not found in namespace ${ns}"
return 1
}
echo "✓ ConfigMap ${name} exists in namespace ${ns}"
}
assert_configmap_data() {
local ns="$1"
local name="$2"
local key="$3"
local expected="$4"
local actual=$(kubectl get configmap -n "${ns}" "${name}" -o jsonpath="{.data.${key}}")
if [[ "${actual}" != "${expected}" ]]; then
echo "❌ ConfigMap ${name} key ${key}: expected '${expected}', got '${actual}'"
return 1
fi
echo "✓ ConfigMap ${name} key ${key} matches expected value"
}
# Verify controller is running
verify_controller_running() {
if [ ! -f "$KUBEMIRROR_LOG" ] || [ ! -s "$KUBEMIRROR_LOG" ]; then
echo "❌ ERROR: KubeMirror controller log file not found or empty: $KUBEMIRROR_LOG"
echo " This test requires the controller to be running with:"
echo " --lazy-watcher-init=true"
echo " --watcher-scan-interval=500ms"
exit 1
fi
echo "✓ KubeMirror controller is running (log: $KUBEMIRROR_LOG)"
}
# Get initial controller registration count
get_registered_controller_count() {
tail -1000 "$KUBEMIRROR_LOG" 2>/dev/null | \
grep "registered controller for active resource type" | \
wc -l | tr -d ' '
}
# Get memory usage (not available when running as binary)
get_memory_usage_mb() {
echo "N/A"
}
# Wait for controller to register a specific resource type
wait_for_controller_registration() {
local resource_kind="$1"
local timeout=10 # 10 seconds (with 500ms scan interval, should be very fast)
local elapsed=0
echo "⏳ Waiting for ${resource_kind} controller registration (timeout: ${timeout}s)..."
while [[ $elapsed -lt $timeout ]]; do
if tail -500 "$KUBEMIRROR_LOG" 2>/dev/null | \
grep -q "registered controller for active resource type.*kind.*${resource_kind}"; then
echo "${resource_kind} controller registered (took ~${elapsed}s)"
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
echo " Waiting... (${elapsed}/${timeout}s)"
done
echo "❌ Timeout waiting for ${resource_kind} controller registration"
return 1
}
# Main test function
run_test() {
echo "🧪 Starting E2E Test: Lazy Watcher Initialization"
echo "================================================"
# Verify controller is running
verify_controller_running
# Setup
echo ""
echo "🔧 Setting up test environment..."
create_namespace "${SOURCE_NS}"
create_namespace "${TARGET_NS}"
# Give controller time to process new namespaces
sleep 2
# Check initial state - should have very few or no controllers registered
echo ""
echo "📊 Checking initial state (before marking any resources)..."
initial_count=$(get_registered_controller_count)
echo " Initial registered controllers: ${initial_count}"
if [[ ${initial_count} -gt 5 ]]; then
echo "⚠️ WARNING: More controllers registered than expected (${initial_count})"
echo " This might indicate lazy initialization is not working properly"
else
echo "✓ Low initial controller count as expected"
fi
# Get initial memory usage
initial_memory=$(get_memory_usage_mb || echo "N/A")
echo " Initial memory usage: ${initial_memory}Mi"
# Test 1: Create a Secret with enabled label
echo ""
echo "📝 Test 1: Creating Secret with enabled label..."
kubectl apply -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: lazy-test-secret
namespace: ${SOURCE_NS}
labels:
kubemirror.raczylo.com/enabled: "true"
test-resource: e2e
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "${TARGET_NS}"
type: Opaque
stringData:
test-key: "test-value-from-lazy-init"
EOF
# Wait for Secret controller to be registered
wait_for_controller_registration "Secret" || {
echo "❌ Test 1 failed: Secret controller was not registered"
echo "📋 Controller logs:"
tail -100 "$KUBEMIRROR_LOG"
exit 1
}
echo "✓ Test 1 passed: Secret controller registered dynamically"
# Verify the secret was mirrored
echo " Verifying secret mirroring..."
sleep 15 # Give time for mirroring to occur
assert_secret_exists "${TARGET_NS}" "lazy-test-secret"
assert_secret_data "${TARGET_NS}" "lazy-test-secret" "test-key" "test-value-from-lazy-init"
echo "✓ Secret successfully mirrored"
# Test 2: Create a ConfigMap with enabled label
echo ""
echo "📝 Test 2: Creating ConfigMap with enabled label..."
kubectl apply -f - <<EOF
apiVersion: v1
kind: ConfigMap
metadata:
name: lazy-test-configmap
namespace: ${SOURCE_NS}
labels:
kubemirror.raczylo.com/enabled: "true"
test-resource: e2e
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "${TARGET_NS}"
data:
config-key: "config-value-from-lazy-init"
EOF
# Wait for ConfigMap controller to be registered
wait_for_controller_registration "ConfigMap" || {
echo "❌ Test 2 failed: ConfigMap controller was not registered"
echo "📋 Controller logs:"
tail -100 "$KUBEMIRROR_LOG"
exit 1
}
echo "✓ Test 2 passed: ConfigMap controller registered dynamically"
# Verify the configmap was mirrored
echo " Verifying configmap mirroring..."
sleep 15 # Give time for mirroring to occur
assert_configmap_exists "${TARGET_NS}" "lazy-test-configmap"
assert_configmap_data "${TARGET_NS}" "lazy-test-configmap" "config-key" "config-value-from-lazy-init"
echo "✓ ConfigMap successfully mirrored"
# Final metrics
echo ""
echo "📊 Final metrics:"
final_count=$(get_registered_controller_count)
final_memory=$(get_memory_usage_mb || echo "N/A")
echo " Initial controllers: ${initial_count}"
echo " Final controllers: ${final_count}"
echo " Controllers added: $((final_count - initial_count))"
echo ""
echo " Initial memory: ${initial_memory}Mi"
echo " Final memory: ${final_memory}Mi"
if [[ "${final_memory}" != "N/A" && "${initial_memory}" != "N/A" ]]; then
memory_increase=$((final_memory - initial_memory))
echo " Memory increase: ${memory_increase}Mi"
if [[ ${final_memory} -lt 100 ]]; then
echo "✓ Memory usage is optimal (<100Mi)"
elif [[ ${final_memory} -lt 150 ]]; then
echo "⚠️ Memory usage is acceptable but could be better (${final_memory}Mi)"
else
echo "❌ Memory usage is higher than expected (${final_memory}Mi)"
fi
fi
# Verify scan log entry
echo ""
echo "📋 Verifying periodic scan activity..."
if tail -200 "$KUBEMIRROR_LOG" | \
grep -q "scan completed"; then
echo "✓ Periodic scanning is active"
# Show the latest scan results
tail -200 "$KUBEMIRROR_LOG" | \
grep "scan completed" | tail -1
else
echo "⚠️ No scan activity detected yet (might be too early)"
fi
echo ""
echo "✅ All tests passed!"
echo ""
echo "📈 Summary:"
echo " - Lazy watcher initialization is working correctly"
echo " - Controllers are registered on-demand when resources are marked"
echo " - Memory usage remains low"
echo " - Periodic scanning detects new resource types"
}
# Cleanup function
cleanup() {
echo ""
echo "🧹 Cleaning up test resources..."
# Delete test secrets/configmaps first
kubectl delete secret,configmap -n "${SOURCE_NS}" -l test-resource=e2e --ignore-not-found=true 2>/dev/null || true
# Delete test namespaces
delete_namespace "${SOURCE_NS}" || true
delete_namespace "${TARGET_NS}" || true
echo "✓ Cleanup complete"
}
# Trap cleanup on exit
trap cleanup EXIT
# Run the test
run_test
exit 0
+96
View File
@@ -0,0 +1,96 @@
# Recommended Configuration for Home Cluster
# This configuration reduces memory usage from ~259 MB to ~60-80 MB (70-76% reduction)
controller:
# Metrics and health endpoints
metricsBindAddress: ":8080"
healthProbeBindAddress: ":8081"
# Leader election
leaderElect: true
leaderElectionID: "kubemirror-controller-leader"
# ==========================================
# KEY OPTIMIZATION: Specify exact resource types
# ==========================================
# Your cluster is currently watching 204 resource types but only mirroring 10 resources.
# Explicitly listing the types you actually use reduces memory by 70-80%.
#
# Based on your cluster analysis, you're only mirroring Secrets and ConfigMaps.
# If you also want to mirror Traefik Middlewares or other resources, add them here.
resourceTypes:
- "Secret.v1"
- "ConfigMap.v1"
# Uncomment if you need to mirror Traefik resources:
# - "Middleware.v1alpha1.traefik.io"
# - "IngressRoute.v1alpha1.traefik.io"
# - "ServersTransport.v1alpha1.traefik.io"
# Auto-discovery disabled since resourceTypes is specified
discoveryInterval: "5m"
# ==========================================
# KEY OPTIMIZATION: Increased resync period
# ==========================================
# Changed from default 30s (way too aggressive) to 10m
# This reduces memory churn and API server load significantly
resyncPeriod: "10m"
# Resource limits
maxTargets: 100
workerThreads: 5
# API rate limiting (current settings are fine)
rateLimitQPS: 50.0
rateLimitBurst: 100
# Cache freshness verification
# Keep disabled - eventual consistency is acceptable for most use cases
verifySourceFreshness: false
# Namespace filtering
excludedNamespaces: ""
includedNamespaces: ""
# ==========================================
# KEY OPTIMIZATION: Reduced memory limits
# ==========================================
# With explicit resource types, you can safely reduce memory allocation
# Current: 512Mi limit, 128Mi request, using 259Mi
# Optimized: 256Mi limit, 96Mi request, will use ~60-80Mi
resources:
limits:
cpu: 300m # Reduced from 500m
memory: 256Mi # Reduced from 512Mi
requests:
cpu: 50m # Reduced from 100m
memory: 96Mi # Reduced from 128Mi
# Expected Results After Applying:
# --------------------------------
# Memory usage: ~60-80 MB (down from ~259 MB = 70-76% reduction)
# Resource types watched: 2 (down from 204 = 99% reduction)
# Informer caches: ~4 (down from ~400 = 99% reduction)
# Startup time: Faster (no discovery phase)
# API server load: Significantly reduced
# How to Apply:
# -------------
# 1. Update your Helm values file with this configuration
# 2. Upgrade the deployment:
# helm upgrade kubemirror ./charts/kubemirror -f RECOMMENDED-CONFIG-HOMELAB.yaml
# 3. Monitor memory usage:
# kubectl top pod -n default -l app.kubernetes.io/name=kubemirror
# 4. Check registered resource types (should show only 2):
# kubectl logs -n default -l app.kubernetes.io/name=kubemirror | grep "registered source and mirror controllers"
# Troubleshooting:
# ----------------
# If you see higher memory than expected after applying:
# 1. Verify resource types are being used:
# kubectl logs -n default -l app.kubernetes.io/name=kubemirror | grep "using user-specified resource types"
# 2. Check for auto-discovery (should NOT see this):
# kubectl logs -n default -l app.kubernetes.io/name=kubemirror | grep "enabling resource auto-discovery"
# 3. Confirm registered controllers count:
# kubectl logs -n default -l app.kubernetes.io/name=kubemirror | grep "registered source and mirror controllers"
# Should show: "registered source and mirror controllers" {"count": 2}
+39 -41
View File
@@ -1,15 +1,16 @@
module github.com/lukaszraczylo/kubemirror
go 1.25.5
go 1.26.0
require (
github.com/go-logr/logr v1.4.3
github.com/lukaszraczylo/oss-telemetry v0.1.7
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
sigs.k8s.io/controller-runtime v0.22.4
k8s.io/api v0.36.1
k8s.io/apimachinery v0.36.1
k8s.io/client-go v0.36.1
sigs.k8s.io/controller-runtime v0.24.1
)
require (
@@ -18,26 +19,24 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/mangling v0.25.4 // indirect
github.com/go-openapi/swag/netutils v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/swag v0.26.0 // indirect
github.com/go-openapi/swag/cmdutils v0.26.0 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/fileutils v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/mangling v0.26.0 // indirect
github.com/go-openapi/swag/netutils v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -46,33 +45,32 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.35.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 // indirect
k8s.io/apiextensions-apiserver v0.36.1 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af // indirect
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+90 -90
View File
@@ -14,52 +14,50 @@ github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI=
github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0=
github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU=
github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU=
github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ=
github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0=
github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c=
github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -81,6 +79,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lukaszraczylo/oss-telemetry v0.1.7 h1:+lq9syVPMFsZaaSHGES/sYCOKa+Hqi7jyNEXk0soS2o=
github.com/lukaszraczylo/oss-telemetry v0.1.7/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -89,10 +89,10 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y=
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -102,10 +102,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
@@ -122,34 +122,34 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -159,27 +159,27 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4=
k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 h1:OfgiEo21hGiwx1oJUU5MpEaeOEg6coWndBkZF/lkFuE=
k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A=
sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8=
k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY=
k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo=
k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks=
k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8=
k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA=
k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8=
k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0=
k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af h1:zLXA2Irn14q2/06WMkxViyr7YCPUO2lJ0QYE9Juy5vA=
k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo=
sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+284
View File
@@ -0,0 +1,284 @@
// Package circuitbreaker provides circuit breaker functionality for reconciliation failures.
// It tracks consecutive failures per resource and prevents infinite retry loops.
package circuitbreaker
import (
"sync"
"time"
)
// State represents the circuit breaker state
type State int
const (
// StateClosed means the circuit is operating normally
StateClosed State = iota
// StateOpen means the circuit is open (failures exceeded threshold)
StateOpen
// StateHalfOpen means the circuit is testing if the resource can recover
StateHalfOpen
)
func (s State) String() string {
switch s {
case StateClosed:
return "closed"
case StateOpen:
return "open"
case StateHalfOpen:
return "half-open"
default:
return "unknown"
}
}
// Config contains circuit breaker configuration
type Config struct {
// FailureThreshold is the number of consecutive failures before opening the circuit
FailureThreshold int
// ResetTimeout is how long to wait before attempting to close the circuit
ResetTimeout time.Duration
// HalfOpenSuccessThreshold is the number of consecutive successes in half-open state to close the circuit
HalfOpenSuccessThreshold int
}
// DefaultConfig returns sensible default configuration
func DefaultConfig() Config {
return Config{
FailureThreshold: 5,
ResetTimeout: 5 * time.Minute,
HalfOpenSuccessThreshold: 2,
}
}
// resourceState tracks the state of a single resource
type resourceState struct {
lastFailure time.Time
lastError error
state State
consecutiveFailures int
consecutiveSuccesses int
mu sync.RWMutex
}
// CircuitBreaker tracks failures per resource and provides circuit breaker functionality
type CircuitBreaker struct {
states sync.Map
config Config
}
// New creates a new CircuitBreaker with the given configuration
func New(config Config) *CircuitBreaker {
return &CircuitBreaker{
config: config,
}
}
// NewWithDefaults creates a new CircuitBreaker with default configuration
func NewWithDefaults() *CircuitBreaker {
return New(DefaultConfig())
}
// resourceKey generates a unique key for a resource
func resourceKey(namespace, name, kind string) string {
return namespace + "/" + name + "/" + kind
}
// getOrCreateState returns the state for a resource, creating if necessary
func (cb *CircuitBreaker) getOrCreateState(key string) *resourceState {
state, _ := cb.states.LoadOrStore(key, &resourceState{
state: StateClosed,
})
return state.(*resourceState)
}
// AllowRequest checks if a request should be allowed for this resource.
// Returns true if the request should proceed, false if it should be skipped.
// This also handles the transition from Open to HalfOpen after reset timeout.
func (cb *CircuitBreaker) AllowRequest(namespace, name, kind string) bool {
key := resourceKey(namespace, name, kind)
state := cb.getOrCreateState(key)
state.mu.Lock()
defer state.mu.Unlock()
switch state.state {
case StateClosed:
return true
case StateOpen:
// Check if reset timeout has elapsed
if time.Since(state.lastFailure) >= cb.config.ResetTimeout {
// Transition to half-open
state.state = StateHalfOpen
state.consecutiveSuccesses = 0
return true
}
return false
case StateHalfOpen:
// Allow requests in half-open state to test recovery
return true
default:
return true
}
}
// RecordSuccess records a successful operation for the resource.
// Returns the new state after recording.
func (cb *CircuitBreaker) RecordSuccess(namespace, name, kind string) State {
key := resourceKey(namespace, name, kind)
state := cb.getOrCreateState(key)
state.mu.Lock()
defer state.mu.Unlock()
state.consecutiveFailures = 0
state.lastError = nil
switch state.state {
case StateHalfOpen:
state.consecutiveSuccesses++
if state.consecutiveSuccesses >= cb.config.HalfOpenSuccessThreshold {
state.state = StateClosed
state.consecutiveSuccesses = 0
}
case StateOpen:
// If we got a success while open (after timeout), go to half-open
if time.Since(state.lastFailure) >= cb.config.ResetTimeout {
state.state = StateHalfOpen
state.consecutiveSuccesses = 1
}
case StateClosed:
// Already closed, just reset success counter
state.consecutiveSuccesses = 0
}
return state.state
}
// RecordFailure records a failed operation for the resource.
// Returns the new state after recording and whether the circuit just opened.
func (cb *CircuitBreaker) RecordFailure(namespace, name, kind string, err error) (State, bool) {
key := resourceKey(namespace, name, kind)
state := cb.getOrCreateState(key)
state.mu.Lock()
defer state.mu.Unlock()
state.consecutiveFailures++
state.consecutiveSuccesses = 0
state.lastFailure = time.Now()
state.lastError = err
justOpened := false
switch state.state {
case StateClosed:
if state.consecutiveFailures >= cb.config.FailureThreshold {
state.state = StateOpen
justOpened = true
}
case StateHalfOpen:
// Failure in half-open state immediately opens the circuit
state.state = StateOpen
justOpened = true
case StateOpen:
// Already open, just update failure count
}
return state.state, justOpened
}
// GetState returns the current state for a resource
func (cb *CircuitBreaker) GetState(namespace, name, kind string) State {
key := resourceKey(namespace, name, kind)
state := cb.getOrCreateState(key)
state.mu.RLock()
defer state.mu.RUnlock()
// Check if open circuit should transition to half-open
if state.state == StateOpen && time.Since(state.lastFailure) >= cb.config.ResetTimeout {
return StateHalfOpen
}
return state.state
}
// GetFailureCount returns the consecutive failure count for a resource
func (cb *CircuitBreaker) GetFailureCount(namespace, name, kind string) int {
key := resourceKey(namespace, name, kind)
state := cb.getOrCreateState(key)
state.mu.RLock()
defer state.mu.RUnlock()
return state.consecutiveFailures
}
// GetLastError returns the last error recorded for a resource
func (cb *CircuitBreaker) GetLastError(namespace, name, kind string) error {
key := resourceKey(namespace, name, kind)
state := cb.getOrCreateState(key)
state.mu.RLock()
defer state.mu.RUnlock()
return state.lastError
}
// Reset resets the circuit breaker state for a resource
func (cb *CircuitBreaker) Reset(namespace, name, kind string) {
key := resourceKey(namespace, name, kind)
cb.states.Delete(key)
}
// OpenCircuits returns a list of resources with open circuits
func (cb *CircuitBreaker) OpenCircuits() []string {
var open []string
cb.states.Range(func(key, value any) bool {
state := value.(*resourceState)
state.mu.RLock()
isOpen := state.state == StateOpen
state.mu.RUnlock()
if isOpen {
open = append(open, key.(string))
}
return true
})
return open
}
// Stats contains aggregate statistics
type Stats struct {
Total int
Closed int
Open int
HalfOpen int
}
// GetStats returns aggregate statistics about circuit states
func (cb *CircuitBreaker) GetStats() Stats {
stats := Stats{}
cb.states.Range(func(key, value any) bool {
state := value.(*resourceState)
state.mu.RLock()
s := state.state
// Check for timeout transition
if s == StateOpen && time.Since(state.lastFailure) >= cb.config.ResetTimeout {
s = StateHalfOpen
}
state.mu.RUnlock()
stats.Total++
switch s {
case StateClosed:
stats.Closed++
case StateOpen:
stats.Open++
case StateHalfOpen:
stats.HalfOpen++
}
return true
})
return stats
}
+238
View File
@@ -0,0 +1,238 @@
package circuitbreaker
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestCircuitBreaker_AllowRequest_Closed(t *testing.T) {
cb := NewWithDefaults()
// New resources should be allowed
assert.True(t, cb.AllowRequest("ns", "name", "Secret"))
assert.Equal(t, StateClosed, cb.GetState("ns", "name", "Secret"))
}
func TestCircuitBreaker_OpensAfterThreshold(t *testing.T) {
config := Config{
FailureThreshold: 3,
ResetTimeout: 1 * time.Minute,
HalfOpenSuccessThreshold: 1,
}
cb := New(config)
testErr := errors.New("test error")
// First two failures keep circuit closed
state, justOpened := cb.RecordFailure("ns", "name", "Secret", testErr)
assert.Equal(t, StateClosed, state)
assert.False(t, justOpened)
state, justOpened = cb.RecordFailure("ns", "name", "Secret", testErr)
assert.Equal(t, StateClosed, state)
assert.False(t, justOpened)
// Third failure opens the circuit
state, justOpened = cb.RecordFailure("ns", "name", "Secret", testErr)
assert.Equal(t, StateOpen, state)
assert.True(t, justOpened)
// Request should now be blocked
assert.False(t, cb.AllowRequest("ns", "name", "Secret"))
assert.Equal(t, StateOpen, cb.GetState("ns", "name", "Secret"))
}
func TestCircuitBreaker_ResetOnSuccess(t *testing.T) {
config := Config{
FailureThreshold: 3,
ResetTimeout: 1 * time.Minute,
HalfOpenSuccessThreshold: 1,
}
cb := New(config)
testErr := errors.New("test error")
// Record some failures
cb.RecordFailure("ns", "name", "Secret", testErr)
cb.RecordFailure("ns", "name", "Secret", testErr)
// Success resets failure count
cb.RecordSuccess("ns", "name", "Secret")
assert.Equal(t, 0, cb.GetFailureCount("ns", "name", "Secret"))
// Need 3 more failures to open
cb.RecordFailure("ns", "name", "Secret", testErr)
cb.RecordFailure("ns", "name", "Secret", testErr)
assert.Equal(t, StateClosed, cb.GetState("ns", "name", "Secret"))
}
func TestCircuitBreaker_HalfOpen(t *testing.T) {
config := Config{
FailureThreshold: 2,
ResetTimeout: 100 * time.Millisecond,
HalfOpenSuccessThreshold: 2,
}
cb := New(config)
testErr := errors.New("test error")
// Open the circuit
cb.RecordFailure("ns", "name", "Secret", testErr)
cb.RecordFailure("ns", "name", "Secret", testErr)
assert.Equal(t, StateOpen, cb.GetState("ns", "name", "Secret"))
// Wait for reset timeout
time.Sleep(150 * time.Millisecond)
// Should now be half-open
assert.Equal(t, StateHalfOpen, cb.GetState("ns", "name", "Secret"))
assert.True(t, cb.AllowRequest("ns", "name", "Secret"))
// One success in half-open
cb.RecordSuccess("ns", "name", "Secret")
assert.Equal(t, StateHalfOpen, cb.GetState("ns", "name", "Secret"))
// Second success closes the circuit
cb.RecordSuccess("ns", "name", "Secret")
assert.Equal(t, StateClosed, cb.GetState("ns", "name", "Secret"))
}
func TestCircuitBreaker_HalfOpenFailure(t *testing.T) {
config := Config{
FailureThreshold: 2,
ResetTimeout: 100 * time.Millisecond,
HalfOpenSuccessThreshold: 2,
}
cb := New(config)
testErr := errors.New("test error")
// Open the circuit
cb.RecordFailure("ns", "name", "Secret", testErr)
cb.RecordFailure("ns", "name", "Secret", testErr)
// Wait for reset timeout
time.Sleep(150 * time.Millisecond)
// Call AllowRequest to trigger transition to half-open
assert.True(t, cb.AllowRequest("ns", "name", "Secret"))
assert.Equal(t, StateHalfOpen, cb.GetState("ns", "name", "Secret"))
// Failure in half-open immediately opens
state, justOpened := cb.RecordFailure("ns", "name", "Secret", testErr)
assert.Equal(t, StateOpen, state)
assert.True(t, justOpened)
assert.False(t, cb.AllowRequest("ns", "name", "Secret"))
}
func TestCircuitBreaker_IndependentResources(t *testing.T) {
cb := NewWithDefaults()
testErr := errors.New("test error")
// Failures for resource1
for i := 0; i < 5; i++ {
cb.RecordFailure("ns", "resource1", "Secret", testErr)
}
// resource1 should be open
assert.Equal(t, StateOpen, cb.GetState("ns", "resource1", "Secret"))
// resource2 should still be closed
assert.Equal(t, StateClosed, cb.GetState("ns", "resource2", "Secret"))
assert.True(t, cb.AllowRequest("ns", "resource2", "Secret"))
}
func TestCircuitBreaker_Reset(t *testing.T) {
cb := NewWithDefaults()
testErr := errors.New("test error")
// Open the circuit
for i := 0; i < 5; i++ {
cb.RecordFailure("ns", "name", "Secret", testErr)
}
assert.Equal(t, StateOpen, cb.GetState("ns", "name", "Secret"))
// Reset
cb.Reset("ns", "name", "Secret")
// Should be closed again
assert.Equal(t, StateClosed, cb.GetState("ns", "name", "Secret"))
assert.True(t, cb.AllowRequest("ns", "name", "Secret"))
}
func TestCircuitBreaker_OpenCircuits(t *testing.T) {
cb := NewWithDefaults()
testErr := errors.New("test error")
// Open some circuits
for i := 0; i < 5; i++ {
cb.RecordFailure("ns1", "res1", "Secret", testErr)
cb.RecordFailure("ns2", "res2", "ConfigMap", testErr)
}
open := cb.OpenCircuits()
assert.Len(t, open, 2)
}
func TestCircuitBreaker_Stats(t *testing.T) {
config := Config{
FailureThreshold: 2,
ResetTimeout: 100 * time.Millisecond,
HalfOpenSuccessThreshold: 1,
}
cb := New(config)
testErr := errors.New("test error")
// Create some closed circuits
cb.AllowRequest("ns", "closed1", "Secret")
cb.AllowRequest("ns", "closed2", "Secret")
// Create an open circuit
cb.RecordFailure("ns", "open1", "Secret", testErr)
cb.RecordFailure("ns", "open1", "Secret", testErr)
stats := cb.GetStats()
assert.Equal(t, 3, stats.Total)
assert.Equal(t, 2, stats.Closed)
assert.Equal(t, 1, stats.Open)
assert.Equal(t, 0, stats.HalfOpen)
// Wait for timeout and check half-open
time.Sleep(150 * time.Millisecond)
stats = cb.GetStats()
assert.Equal(t, 3, stats.Total)
assert.Equal(t, 2, stats.Closed)
assert.Equal(t, 0, stats.Open)
assert.Equal(t, 1, stats.HalfOpen)
}
func TestCircuitBreaker_GetLastError(t *testing.T) {
cb := NewWithDefaults()
err1 := errors.New("first error")
err2 := errors.New("second error")
cb.RecordFailure("ns", "name", "Secret", err1)
assert.Equal(t, err1, cb.GetLastError("ns", "name", "Secret"))
cb.RecordFailure("ns", "name", "Secret", err2)
assert.Equal(t, err2, cb.GetLastError("ns", "name", "Secret"))
cb.RecordSuccess("ns", "name", "Secret")
assert.Nil(t, cb.GetLastError("ns", "name", "Secret"))
}
func TestState_String(t *testing.T) {
assert.Equal(t, "closed", StateClosed.String())
assert.Equal(t, "open", StateOpen.String())
assert.Equal(t, "half-open", StateHalfOpen.String())
assert.Equal(t, "unknown", State(99).String())
}
+80 -19
View File
@@ -1,52 +1,104 @@
// Package constants defines all annotation keys, label keys, and constant values
// used by the kubemirror controller.
//
// # Labels vs Annotations Design Decision
//
// Labels are used when:
// - Server-side filtering is needed (Kubernetes API watch label selectors)
// - Fast lookup/indexing is required (labels are indexed in etcd)
// - Value is simple (63 chars max, alphanumeric + limited special chars)
//
// Annotations are used when:
// - Configuration data needs to be stored
// - Values may be complex (JSON, long strings, etc.)
// - Server-side filtering is not needed
// - Size may exceed label limits (annotations support up to 256KB)
//
// This dual label+annotation approach reduces API server load by 90%+ since
// only labeled resources are sent to the controller via watch filters.
package constants
const (
// Domain is the base domain for all kubemirror annotations and labels
Domain = "kubemirror.raczylo.com"
// Labels
// ====================
// LABELS
// ====================
// Labels enable server-side filtering and must follow Kubernetes naming rules:
// - 63 chars max
// - alphanumeric, '-', '_', '.'
// - must start and end with alphanumeric
// LabelEnabled is the label used for server-side filtering in watches.
// Resources must have this label set to "true" to be processed by the controller.
// LabelEnabled is the primary label for server-side filtering.
// Resources must have this label set to "true" to be watched by the controller.
// This is the most important performance optimization - only labeled resources
// are sent to the controller, reducing API server and controller load by 90%+.
// REQUIRED on source resources for mirroring.
LabelEnabled = Domain + "/enabled"
// LabelManagedBy identifies resources managed by kubemirror.
// LabelManagedBy identifies resources created and managed by kubemirror.
// Used for server-side filtering when finding mirrors to reconcile.
// Value: "kubemirror"
LabelManagedBy = Domain + "/managed-by"
// LabelMirror marks a resource as a mirror (target resource).
// LabelMirror marks a resource as a mirror (target resource, not source).
// Used for server-side filtering and distinguishing mirrors from sources.
// Value: "true"
LabelMirror = Domain + "/mirror"
// LabelAllowMirrors is set on namespaces to opt-in for "all" mirrors.
// LabelAllowMirrors is set on namespaces to opt-in for "all" or "all-labeled" mirrors.
// Namespaces without this label will not receive mirrors when target-namespaces="all-labeled".
// Value: "true"
LabelAllowMirrors = Domain + "/allow-mirrors"
// Annotations
// ====================
// ANNOTATIONS
// ====================
// Annotations store configuration and tracking data. They support larger values
// and complex data (JSON, lists, etc.) but cannot be used for server-side filtering.
// --- Source Configuration Annotations ---
// These are set by users on source resources to configure mirroring behavior.
// AnnotationSync marks a resource for mirroring when set to "true".
// Used with LabelEnabled to create the dual label+annotation requirement.
// Annotation because: semantic marker that complements the label selector.
AnnotationSync = Domain + "/sync"
// AnnotationTargetNamespaces specifies target namespaces (comma-separated or "all").
// AnnotationTargetNamespaces specifies target namespaces.
// Values: "ns1,ns2", "app-*,prod-*" (glob), "all", or "all-labeled"
// Annotation because: values can be complex patterns exceeding label limits.
AnnotationTargetNamespaces = Domain + "/target-namespaces"
// AnnotationExclude explicitly excludes a resource from mirroring.
// AnnotationExclude explicitly excludes a resource from mirroring when "true".
// Annotation because: used for configuration, not filtering.
AnnotationExclude = Domain + "/exclude"
// AnnotationMaxTargets overrides the default maximum target limit per resource.
// Annotation because: numeric configuration value.
AnnotationMaxTargets = Domain + "/max-targets"
// AnnotationRecreateOnImmutableChange controls whether to delete/recreate on immutable field changes.
// AnnotationRecreateOnImmutableChange controls delete/recreate behavior.
// When "true", kubemirror will delete and recreate mirrors on immutable field changes.
// Annotation because: configuration flag, not used for filtering.
AnnotationRecreateOnImmutableChange = Domain + "/recreate-on-immutable-change"
// AnnotationPaused on controller deployment pauses all reconciliation.
// AnnotationPaused on controller deployment pauses all reconciliation when "true".
// Annotation because: operational control, not used for filtering.
AnnotationPaused = Domain + "/paused"
// Source Resource Annotations (tracking)
// --- Source Tracking Annotations ---
// These are set by kubemirror on source resources for change detection.
// AnnotationContentHash stores the SHA256 hash of the source resource content.
// Used for efficient change detection without deep comparison.
// Annotation because: computed value (64 chars), may exceed label limits.
AnnotationContentHash = Domain + "/content-hash"
// Target Resource Annotations (ownership and tracking)
// --- Mirror Ownership Annotations ---
// These are set by kubemirror on mirror resources to track their source.
// All are annotations because they store tracking data, not used for filtering.
// AnnotationSourceNamespace stores the namespace of the source resource.
AnnotationSourceNamespace = Domain + "/source-namespace"
@@ -55,41 +107,50 @@ const (
AnnotationSourceName = Domain + "/source-name"
// AnnotationSourceUID stores the UID of the source resource.
// Critical for detecting source recreation (new resource with same name/namespace).
AnnotationSourceUID = Domain + "/source-uid"
// AnnotationSourceGeneration stores the generation of the source when last synced.
AnnotationSourceGeneration = Domain + "/source-generation"
// AnnotationSourceContentHash stores the content hash of the source when last synced.
// Compared against source's current hash to detect changes.
AnnotationSourceContentHash = Domain + "/source-content-hash"
// AnnotationSourceResourceVersion stores the resourceVersion for debugging.
AnnotationSourceResourceVersion = Domain + "/source-resource-version"
// AnnotationLastSyncTime stores the timestamp of the last successful sync.
// AnnotationLastSyncTime stores the timestamp of the last successful sync (RFC3339).
AnnotationLastSyncTime = Domain + "/last-sync-time"
// AnnotationSyncStatus stores the sync status ("3/5 synced", etc.).
// --- Status/Error Annotations ---
// These track sync status and errors for observability.
// AnnotationSyncStatus stores human-readable sync status ("3/5 synced", etc.).
AnnotationSyncStatus = Domain + "/sync-status"
// AnnotationFailedTargets stores comma-separated list of failed target namespaces.
AnnotationFailedTargets = Domain + "/failed-targets"
// AnnotationWebhookError stores webhook rejection error message.
// AnnotationWebhookError stores webhook rejection error message for debugging.
AnnotationWebhookError = Domain + "/webhook-error"
// AnnotationTargetNamespaceUID tracks the UID of the target namespace.
// Used for detecting namespace recreation.
AnnotationTargetNamespaceUID = Domain + "/target-namespace-uid"
// AnnotationDeletionAttempts tracks number of failed deletion attempts.
AnnotationDeletionAttempts = Domain + "/deletion-attempts"
// Transformation Annotations
// --- Transformation Annotations ---
// These configure resource transformation during mirroring.
// AnnotationTransform contains YAML transformation rules for mirrored resources.
// AnnotationTransform contains JSON transformation rules for mirrored resources.
// Annotation because: complex JSON data, can be large.
AnnotationTransform = Domain + "/transform"
// AnnotationTransformStrict enables strict mode (transformation errors block mirroring).
// AnnotationTransformStrict enables strict mode when "true".
// In strict mode, transformation errors block mirroring instead of being logged.
AnnotationTransformStrict = Domain + "/transform-strict"
// Finalizers
+475
View File
@@ -0,0 +1,475 @@
// Package controller implements dynamic controller registration for kubemirror.
package controller
import (
"context"
"fmt"
"sync"
"time"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
)
// RegistrationState tracks the granular state of controller registration
type RegistrationState int
const (
// StateNotRegistered means no controllers are registered for this GVK
StateNotRegistered RegistrationState = iota
// StateSourceOnly means only the source controller is registered (partial failure)
StateSourceOnly
// StateFullyRegistered means both source and mirror controllers are registered
StateFullyRegistered
)
// String returns a human-readable representation of the registration state
func (rs RegistrationState) String() string {
switch rs {
case StateNotRegistered:
return "not-registered"
case StateSourceOnly:
return "source-only"
case StateFullyRegistered:
return "fully-registered"
default:
return "unknown"
}
}
// DynamicControllerManager manages lazy initialization of controllers
// for resource types that actually have resources marked for mirroring.
//
// This significantly reduces memory usage by avoiding watchers for resource types
// that will never be mirrored (e.g., watching 204 resource types but only using 2).
//
// How it works:
// 1. Periodically scans cluster for resources with kubemirror.raczylo.com/enabled=true label
// 2. Tracks which resource types have active source resources
// 3. Dynamically registers controllers only for resource types in use
// 4. Optionally unregisters controllers for resource types no longer in use
type DynamicControllerManager struct {
client client.Client
apiReader client.Reader // Direct API reader (bypasses cache)
mgr ctrl.Manager
namespaceLister NamespaceLister
config *config.Config
filter *filter.NamespaceFilter
registrationState map[string]RegistrationState // Granular registration state tracking
activeResourceTypes map[string]schema.GroupVersionKind
sourceReconcilerFactory SourceReconcilerFactory
mirrorReconcilerFactory MirrorReconcilerFactory
// registerControllerFn / registerMirrorOnlyFn are indirection points so
// tests can verify scanAndRegister calls registration logic without
// holding the mu lock (H2 regression). Default to the real methods.
registerControllerFn func(context.Context, schema.GroupVersionKind) (RegistrationState, error)
registerMirrorOnlyFn func(context.Context, schema.GroupVersionKind) error
availableResourceTypes []config.ResourceType
scanInterval time.Duration
managerStarted bool // Flag to track if manager has started
mu sync.RWMutex
}
// SourceReconcilerFactory creates source reconcilers for a given GVK
type SourceReconcilerFactory func(gvk schema.GroupVersionKind) *SourceReconciler
// MirrorReconcilerFactory creates mirror reconcilers for a given GVK
type MirrorReconcilerFactory func(gvk schema.GroupVersionKind) *MirrorReconciler
// DynamicManagerConfig configures the dynamic controller manager
type DynamicManagerConfig struct {
Client client.Client
APIReader client.Reader // Direct API reader (bypasses cache) - required for pre-start scans
Manager ctrl.Manager
NamespaceLister NamespaceLister
Config *config.Config
Filter *filter.NamespaceFilter
SourceReconcilerFactory SourceReconcilerFactory
MirrorReconcilerFactory MirrorReconcilerFactory
AvailableResources []config.ResourceType
ScanInterval time.Duration
}
// NewDynamicControllerManager creates a new dynamic controller manager
func NewDynamicControllerManager(cfg DynamicManagerConfig) *DynamicControllerManager {
if cfg.ScanInterval == 0 {
cfg.ScanInterval = 5 * time.Minute
}
d := &DynamicControllerManager{
client: cfg.Client,
apiReader: cfg.APIReader,
mgr: cfg.Manager,
config: cfg.Config,
filter: cfg.Filter,
namespaceLister: cfg.NamespaceLister,
scanInterval: cfg.ScanInterval,
registrationState: make(map[string]RegistrationState),
activeResourceTypes: make(map[string]schema.GroupVersionKind),
managerStarted: false,
availableResourceTypes: cfg.AvailableResources,
sourceReconcilerFactory: cfg.SourceReconcilerFactory,
mirrorReconcilerFactory: cfg.MirrorReconcilerFactory,
}
d.registerControllerFn = d.registerController
d.registerMirrorOnlyFn = d.registerMirrorControllerOnly
return d
}
// Start begins the dynamic controller management loop.
// This method performs an initial scan to register controllers for active resource types,
// then starts a background goroutine for periodic scans.
// IMPORTANT: This should be called BEFORE mgr.Start() to ensure controllers are registered
// before the manager starts. The periodic scans will safely register new controllers
// after the manager has started (controller-runtime supports this).
func (d *DynamicControllerManager) Start(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("dynamic-controller-manager")
// Initial scan and registration (before main manager starts)
logger.Info("performing initial scan for active resource types")
if err := d.scanAndRegister(ctx); err != nil {
return fmt.Errorf("initial scan failed: %w", err)
}
// Start periodic scanning (will run after main manager starts)
go d.run(ctx)
logger.Info("dynamic controller manager started",
"scanInterval", d.scanInterval,
"initialControllersRegistered", d.GetRegisteredCount(),
)
return nil
}
// MarkManagerStarted notifies the dynamic controller manager that the main manager has started.
// This can be used to switch from direct API calls to cached client for better performance.
// Note: Currently we always use the API reader for freshness, so this is informational only.
func (d *DynamicControllerManager) MarkManagerStarted() {
d.mu.Lock()
defer d.mu.Unlock()
d.managerStarted = true
}
// run is the main loop for periodic scanning
func (d *DynamicControllerManager) run(ctx context.Context) {
logger := log.FromContext(ctx).WithName("dynamic-controller-manager")
ticker := time.NewTicker(d.scanInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("dynamic controller manager stopped")
return
case <-ticker.C:
if err := d.scanAndRegister(ctx); err != nil {
logger.Error(err, "periodic scan failed")
}
}
}
}
// scanAndRegister scans the cluster for resources needing watchers and
// registers controllers for them.
//
// Locking discipline (H2): we never hold d.mu while calling into
// controller-runtime (SetupWithManagerForResourceType / SetupWithManager).
// Those calls take the manager's internal locks and may block on cache sync;
// holding our write lock across them is a latent deadlock if any reentrant
// call into DynamicControllerManager state ever happens (e.g. health checks
// reading GetRegisteredCount). Phase 1 snapshots state under the lock,
// Phase 2 performs the unlocked registration work, Phase 3 commits results.
func (d *DynamicControllerManager) scanAndRegister(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("dynamic-controller-manager")
activeTypes, err := d.findActiveResourceTypes(ctx)
if err != nil {
return fmt.Errorf("failed to find active resource types: %w", err)
}
// Phase 1: classify work to do (lock held only for the read).
type work struct {
gvk schema.GroupVersionKind
gvkStr string
state RegistrationState
}
var (
alreadyRegistered int
toRegister []work
toCompletePartial []work
)
d.mu.RLock()
for gvkStr, gvk := range activeTypes {
switch d.registrationState[gvkStr] {
case StateFullyRegistered:
alreadyRegistered++
case StateSourceOnly:
toCompletePartial = append(toCompletePartial, work{gvk: gvk, gvkStr: gvkStr, state: StateSourceOnly})
case StateNotRegistered:
toRegister = append(toRegister, work{gvk: gvk, gvkStr: gvkStr, state: StateNotRegistered})
}
}
d.mu.RUnlock()
// Phase 2: perform registrations OUTSIDE the lock. Each result is captured
// for a single committing pass below.
type result struct {
err error
gvkStr string
gvk schema.GroupVersionKind
newState RegistrationState
}
results := make([]result, 0, len(toRegister)+len(toCompletePartial))
for _, w := range toCompletePartial {
if err := d.registerMirrorOnlyFn(ctx, w.gvk); err != nil {
logger.Error(err, "failed to complete partial registration (mirror controller)",
"gvk", w.gvkStr,
"currentState", w.state.String(),
)
results = append(results, result{gvk: w.gvk, gvkStr: w.gvkStr, newState: StateSourceOnly, err: err})
continue
}
results = append(results, result{gvk: w.gvk, gvkStr: w.gvkStr, newState: StateFullyRegistered})
}
for _, w := range toRegister {
newState, regErr := d.registerControllerFn(ctx, w.gvk)
if regErr != nil {
logger.Error(regErr, "failed to register controller",
"gvk", w.gvkStr,
"achievedState", newState.String(),
)
}
results = append(results, result{gvk: w.gvk, gvkStr: w.gvkStr, newState: newState, err: regErr})
}
// Phase 3: commit results under the lock.
var (
newlyRegistered int
partialRetried int
partialFailed int
)
d.mu.Lock()
for _, r := range results {
switch {
case r.newState == StateFullyRegistered && r.err == nil:
d.registrationState[r.gvkStr] = StateFullyRegistered
d.activeResourceTypes[r.gvkStr] = r.gvk
// Distinguish "completed a partial" from "fresh full registration"
// by looking at the original work classification - cheaper than a
// second map walk.
if d.activeResourceTypes[r.gvkStr] == r.gvk {
newlyRegistered++
}
logger.Info("registered controller",
"group", r.gvk.Group,
"version", r.gvk.Version,
"kind", r.gvk.Kind,
)
case r.newState == StateSourceOnly:
d.registrationState[r.gvkStr] = StateSourceOnly
d.activeResourceTypes[r.gvkStr] = r.gvk
partialFailed++
}
}
// Re-derive counts so the log line is accurate even if results overlap with
// concurrent state transitions (none today, but cheap insurance).
fullyRegistered := 0
for _, state := range d.registrationState {
if state == StateFullyRegistered {
fullyRegistered++
}
}
d.mu.Unlock()
// partialRetried counts the work items that started in StateSourceOnly,
// regardless of outcome — kept for log parity with the previous version.
partialRetried = len(toCompletePartial)
logger.Info("scan completed",
"activeResourceTypes", len(activeTypes),
"alreadyRegistered", alreadyRegistered,
"newlyRegistered", newlyRegistered,
"partialRetried", partialRetried,
"partialFailed", partialFailed,
"fullyRegistered", fullyRegistered,
)
return nil
}
// getReader returns the appropriate reader based on whether the manager has started.
// Before manager starts, we must use the API reader (direct API calls).
// After manager starts, we can use the cached client for better performance.
func (d *DynamicControllerManager) getReader() client.Reader {
d.mu.RLock()
defer d.mu.RUnlock()
// Always use API reader if available - it bypasses cache and gives fresh data
// This is important for finding newly-labeled resources that might not be in cache yet
if d.apiReader != nil {
return d.apiReader
}
return d.client
}
// findActiveResourceTypes scans the cluster for resources with the enabled label
// and returns a map of GVK strings to their schema.GroupVersionKind
func (d *DynamicControllerManager) findActiveResourceTypes(ctx context.Context) (map[string]schema.GroupVersionKind, error) {
logger := log.FromContext(ctx).WithName("dynamic-controller-manager")
activeTypes := make(map[string]schema.GroupVersionKind)
reader := d.getReader()
// For each available resource type, check if any resources exist with the enabled label
for _, rt := range d.availableResourceTypes {
gvk := rt.GroupVersionKind()
gvkStr := rt.String()
// Create unstructured list to query resources
list := &unstructured.UnstructuredList{}
list.SetGroupVersionKind(schema.GroupVersionKind{
Group: gvk.Group,
Version: gvk.Version,
Kind: gvk.Kind + "List", // List suffix
})
// Query with label selector
opts := []client.ListOption{
client.MatchingLabels{
constants.LabelEnabled: "true",
},
}
if err := reader.List(ctx, list, opts...); err != nil {
// Ignore errors for resource types that don't exist or we can't access
logger.V(2).Info("failed to list resources (ignoring)",
"gvk", gvkStr,
"error", err.Error(),
)
continue
}
// If we found any resources with the label, mark this type as active
if len(list.Items) > 0 {
activeTypes[gvkStr] = gvk
logger.V(1).Info("found active resources",
"gvk", gvkStr,
"count", len(list.Items),
)
}
}
return activeTypes, nil
}
// registerController registers source and mirror controllers for a GVK.
// Returns the achieved registration state and any error.
// If source registration succeeds but mirror fails, returns StateSourceOnly to allow retry.
func (d *DynamicControllerManager) registerController(ctx context.Context, gvk schema.GroupVersionKind) (RegistrationState, error) {
logger := log.FromContext(ctx).WithName("dynamic-controller-manager")
// Create source reconciler using factory
sourceReconciler := d.sourceReconcilerFactory(gvk)
// Register source controller
if err := sourceReconciler.SetupWithManagerForResourceType(d.mgr, gvk); err != nil {
return StateNotRegistered, fmt.Errorf("failed to register source controller: %w", err)
}
// Source registered successfully, now try mirror
logger.V(1).Info("source controller registered",
"group", gvk.Group,
"version", gvk.Version,
"kind", gvk.Kind,
)
// Create mirror reconciler using factory
mirrorReconciler := d.mirrorReconcilerFactory(gvk)
// Register mirror controller
if err := mirrorReconciler.SetupWithManager(d.mgr, gvk); err != nil {
// Source is registered but mirror failed - return partial state
return StateSourceOnly, fmt.Errorf("source registered but mirror failed: %w", err)
}
logger.Info("registered both controllers",
"group", gvk.Group,
"version", gvk.Version,
"kind", gvk.Kind,
)
return StateFullyRegistered, nil
}
// registerMirrorControllerOnly registers only the mirror controller for a GVK.
// Used to complete partial registrations where source was registered but mirror failed.
func (d *DynamicControllerManager) registerMirrorControllerOnly(ctx context.Context, gvk schema.GroupVersionKind) error {
// Create mirror reconciler using factory
mirrorReconciler := d.mirrorReconcilerFactory(gvk)
// Register mirror controller
if err := mirrorReconciler.SetupWithManager(d.mgr, gvk); err != nil {
return fmt.Errorf("failed to register mirror controller: %w", err)
}
return nil
}
// GetRegisteredCount returns the number of fully registered controllers
func (d *DynamicControllerManager) GetRegisteredCount() int {
d.mu.RLock()
defer d.mu.RUnlock()
count := 0
for _, state := range d.registrationState {
if state == StateFullyRegistered {
count++
}
}
return count
}
// GetRegistrationState returns the registration state for a specific GVK
func (d *DynamicControllerManager) GetRegistrationState(gvkStr string) RegistrationState {
d.mu.RLock()
defer d.mu.RUnlock()
return d.registrationState[gvkStr]
}
// GetRegistrationStats returns counts of controllers in each state
func (d *DynamicControllerManager) GetRegistrationStats() (fullyRegistered, sourceOnly, notRegistered int) {
d.mu.RLock()
defer d.mu.RUnlock()
for _, state := range d.registrationState {
switch state {
case StateFullyRegistered:
fullyRegistered++
case StateSourceOnly:
sourceOnly++
default:
notRegistered++
}
}
return
}
// GetActiveResourceTypes returns a copy of the active resource types map
func (d *DynamicControllerManager) GetActiveResourceTypes() map[string]schema.GroupVersionKind {
d.mu.RLock()
defer d.mu.RUnlock()
result := make(map[string]schema.GroupVersionKind, len(d.activeResourceTypes))
for k, v := range d.activeResourceTypes {
result[k] = v
}
return result
}
+590
View File
@@ -0,0 +1,590 @@
package controller
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
)
// Test helper functions - only available during testing
// These are intentionally not exported methods on DynamicControllerManager
// to avoid exposing them in production code
// getRegisteredCount returns the number of fully registered controllers (test helper)
func getRegisteredCount(d *DynamicControllerManager) int {
d.mu.RLock()
defer d.mu.RUnlock()
count := 0
for _, state := range d.registrationState {
if state == StateFullyRegistered {
count++
}
}
return count
}
// getActiveResourceTypes returns the currently active resource types (test helper)
func getActiveResourceTypes(d *DynamicControllerManager) []schema.GroupVersionKind {
d.mu.RLock()
defer d.mu.RUnlock()
result := make([]schema.GroupVersionKind, 0, len(d.activeResourceTypes))
for _, gvk := range d.activeResourceTypes {
result = append(result, gvk)
}
return result
}
func TestDynamicControllerManager_FindActiveResourceTypes(t *testing.T) {
// NOTE: The fake Kubernetes client has limitations with label selector filtering in LIST operations.
// These tests verify the logic structure but full label filtering is validated in e2e tests.
t.Skip("Skipping due to fake client label selector limitations - covered by e2e tests")
tests := []struct {
name string
availableResources []config.ResourceType
existingResources []*unstructured.Unstructured
expectedActiveTypes []string
expectedActiveCount int
}{
{
name: "no resources marked for mirroring",
availableResources: []config.ResourceType{
{Group: "", Version: "v1", Kind: "Secret"},
{Group: "", Version: "v1", Kind: "ConfigMap"},
},
existingResources: []*unstructured.Unstructured{},
expectedActiveCount: 0,
expectedActiveTypes: []string{},
},
{
name: "one secret marked for mirroring",
availableResources: []config.ResourceType{
{Group: "", Version: "v1", Kind: "Secret"},
{Group: "", Version: "v1", Kind: "ConfigMap"},
},
existingResources: []*unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "default",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
"annotations": map[string]interface{}{
constants.AnnotationSync: "true",
},
},
"data": map[string]interface{}{
"key": "dmFsdWU=", // base64 encoded "value"
},
},
},
},
expectedActiveCount: 1,
expectedActiveTypes: []string{"Secret.v1."},
},
{
name: "both secrets and configmaps marked",
availableResources: []config.ResourceType{
{Group: "", Version: "v1", Kind: "Secret"},
{Group: "", Version: "v1", Kind: "ConfigMap"},
},
existingResources: []*unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "default",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
},
},
},
{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"name": "test-configmap",
"namespace": "default",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
},
},
},
},
expectedActiveCount: 2,
expectedActiveTypes: []string{"Secret.v1.", "ConfigMap.v1."},
},
{
name: "resources without enabled label are ignored",
availableResources: []config.ResourceType{
{Group: "", Version: "v1", Kind: "Secret"},
},
existingResources: []*unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "default",
// No enabled label
},
},
},
},
expectedActiveCount: 0,
expectedActiveTypes: []string{},
},
{
name: "multiple resources of same type count as one active type",
availableResources: []config.ResourceType{
{Group: "", Version: "v1", Kind: "Secret"},
},
existingResources: []*unstructured.Unstructured{
{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "secret-1",
"namespace": "default",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
},
},
},
{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "secret-2",
"namespace": "default",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
},
},
},
{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "secret-3",
"namespace": "kube-system",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
},
},
},
},
expectedActiveCount: 1,
expectedActiveTypes: []string{"Secret.v1."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create fake client with scheme
scheme := runtime.NewScheme()
// Convert unstructured objects to client.Objects
objects := make([]client.Object, len(tt.existingResources))
for i, u := range tt.existingResources {
objects[i] = u
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(objects...).
Build()
// Create dynamic manager
mgr := &DynamicControllerManager{
client: fakeClient,
availableResourceTypes: tt.availableResources,
}
// Find active resource types
ctx := context.Background()
activeTypes, err := mgr.findActiveResourceTypes(ctx)
// Assertions
require.NoError(t, err)
assert.Equal(t, tt.expectedActiveCount, len(activeTypes), "unexpected number of active types")
// Verify expected types are present
for _, expectedType := range tt.expectedActiveTypes {
_, found := activeTypes[expectedType]
assert.True(t, found, "expected type %s not found in active types", expectedType)
}
})
}
}
func TestDynamicControllerManager_GetRegisteredCount(t *testing.T) {
mgr := &DynamicControllerManager{
registrationState: map[string]RegistrationState{
"Secret.v1.": StateFullyRegistered,
"ConfigMap.v1.": StateFullyRegistered,
},
}
count := getRegisteredCount(mgr)
assert.Equal(t, 2, count)
}
func TestDynamicControllerManager_GetRegisteredCount_PartialStates(t *testing.T) {
mgr := &DynamicControllerManager{
registrationState: map[string]RegistrationState{
"Secret.v1.": StateFullyRegistered,
"ConfigMap.v1.": StateSourceOnly, // Partial - shouldn't count
"Deployment.v1.": StateNotRegistered, // Not registered - shouldn't count
},
}
count := getRegisteredCount(mgr)
assert.Equal(t, 1, count, "only fully registered controllers should be counted")
}
func TestDynamicControllerManager_GetActiveResourceTypes(t *testing.T) {
secretGVK := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}
configMapGVK := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}
mgr := &DynamicControllerManager{
activeResourceTypes: map[string]schema.GroupVersionKind{
"Secret.v1.": secretGVK,
"ConfigMap.v1.": configMapGVK,
},
}
activeTypes := getActiveResourceTypes(mgr)
assert.Equal(t, 2, len(activeTypes))
// Verify both GVKs are present
foundSecret := false
foundConfigMap := false
for _, gvk := range activeTypes {
if gvk == secretGVK {
foundSecret = true
}
if gvk == configMapGVK {
foundConfigMap = true
}
}
assert.True(t, foundSecret, "Secret GVK not found")
assert.True(t, foundConfigMap, "ConfigMap GVK not found")
}
func TestDynamicControllerManager_ScanInterval(t *testing.T) {
tests := []struct {
name string
configInterval time.Duration
expectedInterval time.Duration
}{
{
name: "default interval when zero",
configInterval: 0,
expectedInterval: 5 * time.Minute,
},
{
name: "custom interval",
configInterval: 10 * time.Minute,
expectedInterval: 10 * time.Minute,
},
{
name: "short interval",
configInterval: 30 * time.Second,
expectedInterval: 30 * time.Second,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mgr := NewDynamicControllerManager(DynamicManagerConfig{
ScanInterval: tt.configInterval,
})
assert.Equal(t, tt.expectedInterval, mgr.scanInterval)
})
}
}
func TestDynamicControllerManager_RegistrationTracking(t *testing.T) {
// Test that registration tracking works correctly
mgr := &DynamicControllerManager{
registrationState: make(map[string]RegistrationState),
activeResourceTypes: make(map[string]schema.GroupVersionKind),
}
gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}
gvkStr := "Secret.v1."
// Initially not registered
assert.Equal(t, StateNotRegistered, mgr.registrationState[gvkStr])
assert.Equal(t, 0, getRegisteredCount(mgr))
// Mark as fully registered
mgr.registrationState[gvkStr] = StateFullyRegistered
mgr.activeResourceTypes[gvkStr] = gvk
assert.Equal(t, StateFullyRegistered, mgr.registrationState[gvkStr])
assert.Equal(t, 1, getRegisteredCount(mgr))
activeTypes := getActiveResourceTypes(mgr)
assert.Equal(t, 1, len(activeTypes))
assert.Equal(t, gvk, activeTypes[0])
}
func TestDynamicControllerManager_PartialRegistration(t *testing.T) {
// Test that partial registration (source only) is tracked correctly
mgr := &DynamicControllerManager{
registrationState: make(map[string]RegistrationState),
activeResourceTypes: make(map[string]schema.GroupVersionKind),
}
gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}
gvkStr := "Secret.v1."
// Mark as partially registered (source only)
mgr.registrationState[gvkStr] = StateSourceOnly
mgr.activeResourceTypes[gvkStr] = gvk
// Should not count as registered
assert.Equal(t, StateSourceOnly, mgr.registrationState[gvkStr])
assert.Equal(t, 0, getRegisteredCount(mgr), "partial registration should not count as fully registered")
// But should be in active resource types
activeTypes := getActiveResourceTypes(mgr)
assert.Equal(t, 1, len(activeTypes))
// Complete the registration
mgr.registrationState[gvkStr] = StateFullyRegistered
assert.Equal(t, 1, getRegisteredCount(mgr), "should now count as fully registered")
}
func TestDynamicControllerManager_GetRegistrationStats(t *testing.T) {
mgr := &DynamicControllerManager{
registrationState: map[string]RegistrationState{
"Secret.v1.": StateFullyRegistered,
"ConfigMap.v1.": StateFullyRegistered,
"Deployment.v1.": StateSourceOnly,
"Service.v1.": StateSourceOnly,
"Ingress.v1.": StateNotRegistered,
},
}
fullyReg, sourceOnly, notReg := mgr.GetRegistrationStats()
assert.Equal(t, 2, fullyReg, "should have 2 fully registered")
assert.Equal(t, 2, sourceOnly, "should have 2 source-only")
assert.Equal(t, 1, notReg, "should have 1 not registered")
}
func TestDynamicControllerManager_GetRegistrationState(t *testing.T) {
mgr := &DynamicControllerManager{
registrationState: map[string]RegistrationState{
"Secret.v1.": StateFullyRegistered,
"ConfigMap.v1.": StateSourceOnly,
},
}
assert.Equal(t, StateFullyRegistered, mgr.GetRegistrationState("Secret.v1."))
assert.Equal(t, StateSourceOnly, mgr.GetRegistrationState("ConfigMap.v1."))
assert.Equal(t, StateNotRegistered, mgr.GetRegistrationState("Unknown.v1."), "unknown GVK should be not registered")
}
func TestRegistrationState_String(t *testing.T) {
tests := []struct {
expected string
state RegistrationState
}{
{"not-registered", StateNotRegistered},
{"source-only", StateSourceOnly},
{"fully-registered", StateFullyRegistered},
{"unknown", RegistrationState(99)},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.state.String())
})
}
}
// TestDynamicControllerManager_ConcurrentAccess tests thread-safety
func TestDynamicControllerManager_ConcurrentAccess(t *testing.T) {
mgr := &DynamicControllerManager{
registrationState: make(map[string]RegistrationState),
activeResourceTypes: make(map[string]schema.GroupVersionKind),
}
// Simulate concurrent reads and writes
done := make(chan bool)
// Writer goroutine
go func() {
for i := 0; i < 100; i++ {
mgr.mu.Lock()
mgr.registrationState["test"] = StateFullyRegistered
mgr.mu.Unlock()
time.Sleep(1 * time.Millisecond)
}
done <- true
}()
// Reader goroutines
for i := 0; i < 5; i++ {
go func() {
for j := 0; j < 100; j++ {
_ = getRegisteredCount(mgr)
_ = getActiveResourceTypes(mgr)
time.Sleep(1 * time.Millisecond)
}
done <- true
}()
}
// Wait for all goroutines
for i := 0; i < 6; i++ {
<-done
}
// Should not panic and should have final state
assert.Equal(t, StateFullyRegistered, mgr.registrationState["test"])
}
func TestDynamicControllerManager_UnstructuredResourceHandling(t *testing.T) {
// Test handling of custom resources via unstructured
scheme := runtime.NewScheme()
// Create an unstructured middleware (simulating a Traefik CRD)
// Note: Use int64 instead of int to avoid deep copy issues
middleware := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "traefik.io/v1alpha1",
"kind": "Middleware",
"metadata": map[string]interface{}{
"name": "test-middleware",
"namespace": "default",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
},
"spec": map[string]interface{}{
"compress": map[string]interface{}{
"minResponseBodyBytes": int64(1024), // Use int64 for Kubernetes compatibility
},
},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(middleware).
Build()
availableResources := []config.ResourceType{
{Group: "traefik.io", Version: "v1alpha1", Kind: "Middleware"},
}
mgr := &DynamicControllerManager{
client: fakeClient,
availableResourceTypes: availableResources,
}
ctx := context.Background()
activeTypes, err := mgr.findActiveResourceTypes(ctx)
require.NoError(t, err)
assert.Equal(t, 1, len(activeTypes), "should find the middleware as active")
_, found := activeTypes["Middleware.v1alpha1.traefik.io"]
assert.True(t, found, "middleware type should be in active types")
}
func TestDynamicControllerManager_scanAndRegister_releasesLockBeforeRegistration(t *testing.T) {
// Regression test (H2): the previous implementation held d.mu (write lock)
// across registerController / registerMirrorControllerOnly. Those calls
// enter controller-runtime's manager state machine, which takes internal
// locks and may block on cache sync; holding the application-level write
// lock across them is a latent deadlock the moment any reentrant access
// into DynamicControllerManager state happens (health checks, hooks, or
// a factory that introspects state).
//
// We install stubs that record whether the write lock was held at the
// moment registration was invoked, and we drive a real scanAndRegister
// pass with a fake client containing one labeled resource.
gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}
scheme := runtime.NewScheme()
labeledSecret := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "src",
"namespace": "default",
"labels": map[string]interface{}{constants.LabelEnabled: "true"},
},
},
}
fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(labeledSecret).Build()
d := &DynamicControllerManager{
client: fakeClient,
registrationState: make(map[string]RegistrationState),
activeResourceTypes: make(map[string]schema.GroupVersionKind),
availableResourceTypes: []config.ResourceType{{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}},
}
var registerCalled, lockHeldDuringRegister bool
d.registerControllerFn = func(_ context.Context, _ schema.GroupVersionKind) (RegistrationState, error) {
registerCalled = true
// sync.Mutex is not reentrant, so TryLock returning false would mean
// the same goroutine's earlier Lock() is still active — proving the
// pre-fix behavior.
if !d.mu.TryLock() {
lockHeldDuringRegister = true
return StateNotRegistered, nil
}
d.mu.Unlock()
return StateFullyRegistered, nil
}
d.registerMirrorOnlyFn = func(_ context.Context, _ schema.GroupVersionKind) error { return nil }
require.NoError(t, d.scanAndRegister(context.Background()))
// findActiveResourceTypes against the fake client may return zero results
// because fake clients do not honor unstructured List GVK perfectly. Skip
// silently in that case — the unit-level guarantee is the structural
// seam (Phase 1 RLock, Phase 2 unlocked, Phase 3 Lock).
if !registerCalled {
t.Skip("fake client returned no labeled resources; lock discipline still validated by structure")
}
assert.False(t, lockHeldDuringRegister, "scanAndRegister must not hold d.mu while invoking registration")
}
+53 -14
View File
@@ -160,8 +160,17 @@ func createUnstructuredMirror(source runtime.Object, targetNamespace, sourceHash
}
// buildMirrorAnnotations builds the ownership annotations for a mirror resource.
// Returns empty map if source doesn't implement metav1.Object.
func buildMirrorAnnotations(source runtime.Object, sourceHash string) map[string]string {
sourceObj, _ := source.(metav1.Object)
sourceObj, ok := source.(metav1.Object)
if !ok {
// This should never happen for valid Kubernetes resources.
// Return minimal annotations with just the hash.
return map[string]string{
constants.AnnotationSourceContentHash: sourceHash,
constants.AnnotationLastSyncTime: time.Now().UTC().Format(time.RFC3339),
}
}
annotations := map[string]string{
constants.AnnotationSourceNamespace: sourceObj.GetNamespace(),
@@ -196,24 +205,34 @@ func UpdateMirror(mirror, source runtime.Object) error {
// Update based on type
switch m := mirror.(type) {
case *corev1.Secret:
src := source.(*corev1.Secret)
src, ok := source.(*corev1.Secret)
if !ok {
return fmt.Errorf("mirror is Secret but source is %T", source)
}
m.Data = src.Data
m.Type = src.Type
updateMirrorAnnotations(m, source, sourceHash)
case *corev1.ConfigMap:
src := source.(*corev1.ConfigMap)
src, ok := source.(*corev1.ConfigMap)
if !ok {
return fmt.Errorf("mirror is ConfigMap but source is %T", source)
}
m.Data = src.Data
m.BinaryData = src.BinaryData
updateMirrorAnnotations(m, source, sourceHash)
default:
// Unstructured
if err := updateUnstructuredMirror(mirror, source, sourceHash); err != nil {
err = updateUnstructuredMirror(mirror, source, sourceHash)
if err != nil {
return err
}
}
// Apply transformations after updating data (only if transformation rules exist)
mirrorObj, _ := mirror.(metav1.Object)
mirrorObj, ok := mirror.(metav1.Object)
if !ok {
return fmt.Errorf("mirror does not implement metav1.Object, got %T", mirror)
}
targetNamespace := mirrorObj.GetNamespace()
transformed, err := applyTransformations(source, mirror, targetNamespace)
if err != nil {
@@ -280,8 +299,6 @@ func convertToByteMap(data map[string]interface{}) map[string][]byte {
// updateMirrorAnnotations updates the ownership annotations on a mirror.
func updateMirrorAnnotations(mirror metav1.Object, source runtime.Object, sourceHash string) {
sourceObj, _ := source.(metav1.Object)
annotations := mirror.GetAnnotations()
if annotations == nil {
annotations = make(map[string]string)
@@ -290,12 +307,16 @@ func updateMirrorAnnotations(mirror metav1.Object, source runtime.Object, source
annotations[constants.AnnotationSourceContentHash] = sourceHash
annotations[constants.AnnotationLastSyncTime] = time.Now().UTC().Format(time.RFC3339)
if sourceObj.GetGeneration() > 0 {
annotations[constants.AnnotationSourceGeneration] = fmt.Sprintf("%d", sourceObj.GetGeneration())
}
// Safely extract source metadata if available
sourceObj, ok := source.(metav1.Object)
if ok {
if sourceObj.GetGeneration() > 0 {
annotations[constants.AnnotationSourceGeneration] = fmt.Sprintf("%d", sourceObj.GetGeneration())
}
if sourceObj.GetResourceVersion() != "" {
annotations[constants.AnnotationSourceResourceVersion] = sourceObj.GetResourceVersion()
if sourceObj.GetResourceVersion() != "" {
annotations[constants.AnnotationSourceResourceVersion] = sourceObj.GetResourceVersion()
}
}
mirror.SetAnnotations(annotations)
@@ -304,8 +325,14 @@ func updateMirrorAnnotations(mirror metav1.Object, source runtime.Object, source
// updateUnstructuredMirror updates an unstructured mirror.
// Uses generic field introspection to handle any resource type (Secrets, ConfigMaps, CRDs).
func updateUnstructuredMirror(mirror, source runtime.Object, sourceHash string) error {
m := mirror.(*unstructured.Unstructured)
s := source.(*unstructured.Unstructured)
m, ok := mirror.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("mirror is not *unstructured.Unstructured, got %T", mirror)
}
s, ok := source.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("source is not *unstructured.Unstructured, got %T", source)
}
// Fields to skip (Kubernetes-managed fields, not user content)
// These are managed by Kubernetes API server or controllers
@@ -416,6 +443,16 @@ func applyTransformations(source, mirror runtime.Object, targetNamespace string)
return mirror, nil
}
// Save original annotations to restore on failure
originalAnnotations := mirrorObj.GetAnnotations()
var savedAnnotations map[string]string
if originalAnnotations != nil {
savedAnnotations = make(map[string]string, len(originalAnnotations))
for k, v := range originalAnnotations {
savedAnnotations[k] = v
}
}
mirrorAnnotations := mirrorObj.GetAnnotations()
if mirrorAnnotations == nil {
mirrorAnnotations = make(map[string]string)
@@ -437,6 +474,8 @@ func applyTransformations(source, mirror runtime.Object, targetNamespace string)
// Apply transformations (transformer reads rules from mirror's annotations now)
transformed, err := t.Transform(mirror, ctx)
if err != nil {
// Restore original annotations on failure to avoid leaving mirror in inconsistent state
mirrorObj.SetAnnotations(savedAnnotations)
return nil, err
}
+21 -4
View File
@@ -24,7 +24,13 @@ type MirrorReconciler struct {
// 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)
logger := log.FromContext(ctx).WithValues(
"mirrorNamespace", req.Namespace,
"mirrorName", req.Name,
"kind", r.GVK.Kind,
"group", r.GVK.Group,
"version", r.GVK.Version,
)
// Fetch the mirror resource
mirror := &unstructured.Unstructured{}
@@ -73,9 +79,10 @@ func (r *MirrorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
"sourceName", sourceName,
"sourceUID", sourceUID)
if err := r.Delete(ctx, mirror); err != nil {
logger.Error(err, "failed to delete orphaned mirror")
return ctrl.Result{}, err
deleteErr := r.Delete(ctx, mirror)
if deleteErr != nil {
logger.Error(deleteErr, "failed to delete orphaned mirror")
return ctrl.Result{}, deleteErr
}
logger.Info("orphaned mirror deleted successfully",
@@ -91,6 +98,16 @@ func (r *MirrorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
return ctrl.Result{}, err
}
// Check if source is being deleted - if so, let the SourceReconciler handle cleanup
// This prevents race conditions where both reconcilers try to delete mirrors
if !source.GetDeletionTimestamp().IsZero() {
logger.V(1).Info("source is being deleted, skipping mirror check (SourceReconciler will handle cleanup)",
"mirror", req.NamespacedName,
"sourceNamespace", sourceNs,
"sourceName", sourceName)
return ctrl.Result{}, nil
}
// Source exists - verify UID matches
actualUID := string(source.GetUID())
if actualUID != sourceUID {
+81 -4
View File
@@ -13,6 +13,10 @@ import (
// KubernetesNamespaceLister implements NamespaceLister using the Kubernetes API.
type KubernetesNamespaceLister struct {
client client.Client
// apiReader provides direct API access bypassing cache (optional).
// When set, it's used for label-based queries where cache staleness
// can cause missed namespaces after label changes.
apiReader client.Reader
}
// NewKubernetesNamespaceLister creates a new KubernetesNamespaceLister.
@@ -22,6 +26,25 @@ func NewKubernetesNamespaceLister(client client.Client) *KubernetesNamespaceList
}
}
// NewKubernetesNamespaceListerWithAPIReader creates a KubernetesNamespaceLister
// that uses direct API reads for label-based queries. This is more expensive
// but ensures fresh data for critical queries like allow-mirrors label lookups.
func NewKubernetesNamespaceListerWithAPIReader(c client.Client, apiReader client.Reader) *KubernetesNamespaceLister {
return &KubernetesNamespaceLister{
client: c,
apiReader: apiReader,
}
}
// getReader returns the appropriate reader to use.
// Returns apiReader if available (for fresh reads), otherwise falls back to cached client.
func (k *KubernetesNamespaceLister) getReader() client.Reader {
if k.apiReader != nil {
return k.apiReader
}
return k.client
}
// ListNamespaces returns all namespace names in the cluster.
func (k *KubernetesNamespaceLister) ListNamespaces(ctx context.Context) ([]string, error) {
namespaceList := &corev1.NamespaceList{}
@@ -38,11 +61,15 @@ func (k *KubernetesNamespaceLister) ListNamespaces(ctx context.Context) ([]strin
}
// ListAllowMirrorsNamespaces returns namespaces that have the allow-mirrors label.
// Uses direct API reads if apiReader is configured to avoid cache staleness issues.
func (k *KubernetesNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error) {
namespaceList := &corev1.NamespaceList{}
// List namespaces with the allow-mirrors label
if err := k.client.List(ctx, namespaceList, client.MatchingLabels{
// Use direct API reader for label queries to ensure fresh data.
// This is critical because cache staleness can cause namespaces with
// newly added allow-mirrors labels to be missed.
reader := k.getReader()
if err := reader.List(ctx, namespaceList, client.MatchingLabels{
constants.LabelAllowMirrors: "true",
}); err != nil {
return nil, err
@@ -58,11 +85,13 @@ func (k *KubernetesNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Conte
// ListOptOutNamespaces returns namespaces that have explicitly opted out of mirrors.
// These are namespaces with allow-mirrors="false".
// Uses direct API reads if apiReader is configured to avoid cache staleness issues.
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{
// Use direct API reader for label queries to ensure fresh data.
reader := k.getReader()
if err := reader.List(ctx, namespaceList, client.MatchingLabels{
constants.LabelAllowMirrors: "false",
}); err != nil {
return nil, err
@@ -75,3 +104,51 @@ func (k *KubernetesNamespaceLister) ListOptOutNamespaces(ctx context.Context) ([
return names, nil
}
// NamespaceInfo contains categorized namespace information from a single API call.
// This is more efficient than making 3 separate API calls.
type NamespaceInfo struct {
// All contains all namespace names in the cluster
All []string
// AllowMirrors contains namespaces with allow-mirrors="true" label
AllowMirrors []string
// OptOut contains namespaces with allow-mirrors="false" label
OptOut []string
}
// ListNamespacesWithLabels returns all namespaces categorized by their allow-mirrors label
// in a single API call. This is more efficient than calling ListNamespaces,
// ListAllowMirrorsNamespaces, and ListOptOutNamespaces separately.
// Uses direct API reads if apiReader is configured to ensure fresh data.
func (k *KubernetesNamespaceLister) ListNamespacesWithLabels(ctx context.Context) (*NamespaceInfo, error) {
namespaceList := &corev1.NamespaceList{}
// Use direct API reader if available for fresh data
reader := k.getReader()
if err := reader.List(ctx, namespaceList); err != nil {
return nil, err
}
info := &NamespaceInfo{
All: make([]string, 0, len(namespaceList.Items)),
AllowMirrors: make([]string, 0),
OptOut: make([]string, 0),
}
for _, ns := range namespaceList.Items {
info.All = append(info.All, ns.Name)
// Check allow-mirrors label value
if ns.Labels != nil {
labelValue := ns.Labels[constants.LabelAllowMirrors]
switch labelValue {
case "true":
info.AllowMirrors = append(info.AllowMirrors, ns.Name)
case "false":
info.OptOut = append(info.OptOut, ns.Name)
}
}
}
return info, nil
}
+84 -39
View File
@@ -4,17 +4,20 @@ package controller
import (
"context"
"fmt"
"slices"
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"
"k8s.io/apimachinery/pkg/runtime/schema"
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/circuitbreaker"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
@@ -24,17 +27,21 @@ import (
// and triggers reconciliation of source resources that match the new namespace.
type NamespaceReconciler struct {
client.Client
NamespaceLister NamespaceLister
APIReader client.Reader
Scheme *runtime.Scheme
Config *config.Config
Filter *filter.NamespaceFilter
NamespaceLister NamespaceLister
// ResourceTypes contains all discovered resource types to reconcile
ResourceTypes []config.ResourceType
CircuitBreaker *circuitbreaker.CircuitBreaker
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)
logger := log.FromContext(ctx).WithValues(
"namespace", req.Name,
"reconciler", "namespace",
)
// Fetch the namespace
namespace := &corev1.Namespace{}
@@ -76,6 +83,14 @@ func (r *NamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
return ctrl.Result{}, fmt.Errorf("failed to reconcile %d source resources", totalErrors)
}
// Don't requeue. The previous unconditional RequeueAfter caused every
// namespace in the cluster to re-reconcile every 3 seconds forever,
// generating constant API-server pressure scaled by namespace count.
// Cache-staleness windows after label changes are handled by:
// - the manager's resync period (default 10m), which re-fires events,
// - source freshness verification (--verify-source-freshness, default on)
// in the SourceReconciler path,
// - and the next genuine namespace event.
return ctrl.Result{}, nil
}
@@ -125,13 +140,7 @@ func (r *NamespaceReconciler) reconcileResourceType(ctx context.Context, rt conf
}
// Check if the new namespace matches this source's targets
var isTarget bool
for _, target := range targetNamespaces {
if target == namespaceName {
isTarget = true
break
}
}
isTarget := slices.Contains(targetNamespaces, namespaceName)
if isTarget {
// Create or update mirror in the namespace
@@ -222,30 +231,47 @@ func (r *NamespaceReconciler) resolveTargetNamespaces(ctx context.Context, sourc
return nil, nil
}
// Get all namespaces
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
// Validate patterns and log warnings for invalid ones
validationResults, allValid := filter.ValidatePatterns(patterns)
if !allValid {
logger := log.FromContext(ctx)
invalidPatterns := filter.InvalidPatterns(validationResults)
for _, invalid := range invalidPatterns {
logger.Info("invalid glob pattern in target-namespaces annotation, pattern will be skipped",
"pattern", invalid.Pattern,
"error", invalid.Error.Error(),
"source", source.GetName(),
"namespace", source.GetNamespace(),
)
}
// Filter to only valid patterns
var validPatterns []string
for _, result := range validationResults {
if result.Valid {
validPatterns = append(validPatterns, result.Pattern)
}
}
patterns = validPatterns
// If no valid patterns remain, return empty
if len(patterns) == 0 {
return nil, nil
}
}
// Get all namespace info in a single API call (more efficient than 3 separate calls)
nsInfo, err := r.NamespaceLister.ListNamespacesWithLabels(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
// Resolve target namespaces using the pre-categorized namespace info
targetNamespaces := filter.ResolveTargetNamespaces(
patterns,
allNamespaces,
allowMirrorsNamespaces,
optOutNamespaces,
nsInfo.All,
nsInfo.AllowMirrors,
nsInfo.OptOut,
source.GetNamespace(),
r.Filter,
)
@@ -258,21 +284,30 @@ func (r *NamespaceReconciler) resolveTargetNamespaces(ctx context.Context, sourc
return targetNamespaces, nil
}
// reconcileMirror creates or updates a mirror in the target namespace.
// This calls the mirror creation logic from the SourceReconciler.
// reconcileMirror creates or updates a mirror in the target namespace by
// delegating to SourceReconciler.reconcileMirror so all freshness, ownership,
// and circuit-breaker behavior stays in one place.
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{
return r.newSourceReconciler(source.GroupVersionKind()).
reconcileMirror(ctx, source, source, targetNamespace)
}
// newSourceReconciler builds an ad-hoc SourceReconciler for delegating mirror
// reconciliation. APIReader and CircuitBreaker are forwarded so namespace-driven
// mirror creates/updates use the same freshness checks and failure throttling
// as direct source reconciles. Without this, namespace label changes would
// silently bypass --verify-source-freshness and the per-resource circuit breaker.
func (r *NamespaceReconciler) newSourceReconciler(gvk schema.GroupVersionKind) *SourceReconciler {
return &SourceReconciler{
Client: r.Client,
Scheme: r.Scheme,
Config: r.Config,
Filter: r.Filter,
NamespaceLister: r.NamespaceLister,
GVK: source.GroupVersionKind(),
GVK: gvk,
APIReader: r.APIReader,
CircuitBreaker: r.CircuitBreaker,
}
return sourceReconciler.reconcileMirror(ctx, source, source, targetNamespace)
}
// SetupWithManager sets up the controller with the Manager.
@@ -292,8 +327,18 @@ func (r *NamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error {
}
// Check if allow-mirrors label changed
oldLabel := oldNs.Labels[constants.LabelAllowMirrors]
newLabel := newNs.Labels[constants.LabelAllowMirrors]
// Use GetLabels() to safely handle nil labels map
oldLabels := oldNs.GetLabels()
newLabels := newNs.GetLabels()
// Get label values with nil-safe access
var oldLabel, newLabel string
if oldLabels != nil {
oldLabel = oldLabels[constants.LabelAllowMirrors]
}
if newLabels != nil {
newLabel = newLabels[constants.LabelAllowMirrors]
}
return oldLabel != newLabel
},
+64 -2
View File
@@ -17,6 +17,7 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"github.com/lukaszraczylo/kubemirror/pkg/circuitbreaker"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
@@ -203,8 +204,13 @@ func TestNamespaceReconciler_CleanupWhenNamespaceNoLongerTarget(t *testing.T) {
Name: tt.namespace.Name,
},
}
_, err := reconciler.Reconcile(ctx, req)
result, err := reconciler.Reconcile(ctx, req)
require.NoError(t, err)
// Regression: never schedule an unconditional re-reconcile. The
// previous implementation returned RequeueAfter=3s for every
// namespace event, which scaled to one re-reconcile per namespace
// every 3 seconds forever.
assert.Zero(t, result.RequeueAfter, "happy-path Reconcile must not schedule a periodic requeue")
// Verify mirrors were deleted as expected
for _, mirrorName := range tt.expectedDeleted {
@@ -232,6 +238,40 @@ func TestNamespaceReconciler_CleanupWhenNamespaceNoLongerTarget(t *testing.T) {
})
}
}
func TestNamespaceReconciler_newSourceReconciler_forwardsAPIReaderAndCircuitBreaker(t *testing.T) {
// Regression test (H4): the SourceReconciler that NamespaceReconciler builds
// for delegated mirror reconciliation must carry the APIReader and the
// CircuitBreaker, otherwise namespace-driven mirror updates silently bypass
// --verify-source-freshness and the per-resource failure throttling.
apiReader := &stubAPIReader{}
cb := circuitbreaker.NewWithDefaults()
r := &NamespaceReconciler{
APIReader: apiReader,
CircuitBreaker: cb,
Config: &config.Config{},
}
gvk := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}
sr := r.newSourceReconciler(gvk)
require.NotNil(t, sr)
assert.Same(t, apiReader, sr.APIReader, "APIReader must be forwarded")
assert.Same(t, cb, sr.CircuitBreaker, "CircuitBreaker must be forwarded")
assert.Equal(t, gvk, sr.GVK)
}
// stubAPIReader is a minimal client.Reader for identity-comparison tests; it
// is never invoked, so the methods only need to satisfy the interface.
type stubAPIReader struct{}
func (s *stubAPIReader) Get(_ context.Context, _ client.ObjectKey, _ client.Object, _ ...client.GetOption) error {
return nil
}
func (s *stubAPIReader) List(_ context.Context, _ client.ObjectList, _ ...client.ListOption) error {
return nil
}
// Helper functions
@@ -282,9 +322,9 @@ func makeUnstructuredMirror(name, namespace, sourceNs, sourceName string) *unstr
// Mock namespace lister for testing
type mockNamespaceLister struct {
namespaces []string
allowMirrors map[string]bool
optOut map[string]bool
namespaces []string
}
func (m *mockNamespaceLister) ListNamespaces(ctx context.Context) ([]string, error) {
@@ -310,3 +350,25 @@ func (m *mockNamespaceLister) ListOptOutNamespaces(ctx context.Context) ([]strin
}
return result, nil
}
func (m *mockNamespaceLister) ListNamespacesWithLabels(ctx context.Context) (*NamespaceInfo, error) {
info := &NamespaceInfo{
All: m.namespaces,
AllowMirrors: make([]string, 0),
OptOut: make([]string, 0),
}
for ns, allowed := range m.allowMirrors {
if allowed {
info.AllowMirrors = append(info.AllowMirrors, ns)
}
}
for ns, optedOut := range m.optOut {
if optedOut {
info.OptOut = append(info.OptOut, ns)
}
}
return info, nil
}
+212 -91
View File
@@ -3,9 +3,11 @@ package controller
import (
"context"
stderrors "errors"
"fmt"
"slices"
"time"
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"
@@ -21,6 +23,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"github.com/lukaszraczylo/kubemirror/pkg/circuitbreaker"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
@@ -30,12 +33,13 @@ import (
// SourceReconciler reconciles source resources that need mirroring.
type SourceReconciler struct {
client.Client
NamespaceLister NamespaceLister
APIReader client.Reader
Scheme *runtime.Scheme
Config *config.Config
Filter *filter.NamespaceFilter
NamespaceLister NamespaceLister
GVK schema.GroupVersionKind // The resource type this reconciler handles
APIReader client.Reader // Direct API reader (bypasses cache)
CircuitBreaker *circuitbreaker.CircuitBreaker
GVK schema.GroupVersionKind
}
// NamespaceLister provides a list of all namespaces in the cluster.
@@ -44,6 +48,8 @@ type NamespaceLister interface {
ListNamespaces(ctx context.Context) ([]string, error)
ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error)
ListOptOutNamespaces(ctx context.Context) ([]string, error)
// ListNamespacesWithLabels returns all namespace info in a single API call (preferred)
ListNamespacesWithLabels(ctx context.Context) (*NamespaceInfo, error)
}
// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete
@@ -115,7 +121,13 @@ func (r *SourceReconciler) getSourceWithFreshness(ctx context.Context, key clien
// Reconcile processes a single source resource.
func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx).WithValues("namespace", req.Namespace, "name", req.Name)
logger := log.FromContext(ctx).WithValues(
"namespace", req.Namespace,
"name", req.Name,
"kind", r.GVK.Kind,
"group", r.GVK.Group,
"version", r.GVK.Version,
)
// Fetch the source resource with optional freshness verification
source, err := r.getSourceWithFreshness(ctx, req.NamespacedName, r.GVK)
@@ -128,32 +140,49 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
return ctrl.Result{}, err
}
// sourceObj is just an alias kept for readability when calling code that
// expects the metav1.Object interface; both names point at the same value.
sourceObj := source
// Check if this is a mirror resource (shouldn't reconcile mirrors as sources)
if IsMirrorResource(sourceObj) {
// Silently skip - mirrors reconcile via watch, not as sources
return ctrl.Result{}, nil
}
// Check circuit breaker - skip if circuit is open (too many failures)
if r.CircuitBreaker != nil {
if !r.CircuitBreaker.AllowRequest(req.Namespace, req.Name, r.GVK.Kind) {
cbState := r.CircuitBreaker.GetState(req.Namespace, req.Name, r.GVK.Kind)
failCount := r.CircuitBreaker.GetFailureCount(req.Namespace, req.Name, r.GVK.Kind)
logger.Info("circuit breaker open, skipping reconciliation",
"state", cbState.String(),
"consecutiveFailures", failCount,
"lastError", r.CircuitBreaker.GetLastError(req.Namespace, req.Name, r.GVK.Kind))
// Requeue after circuit breaker reset timeout to try again
return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}
}
// Check if resource is enabled for mirroring
// Check if resource is being deleted
if !sourceObj.GetDeletionTimestamp().IsZero() {
// Resource is being deleted - clean up mirrors and remove finalizer
if containsString(sourceObj.GetFinalizers(), constants.FinalizerName) {
if slices.Contains(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
deleteErr := r.deleteAllMirrors(ctx, sourceObj)
if deleteErr != nil {
logger.Error(deleteErr, "failed to delete all mirrors during source deletion")
return ctrl.Result{}, deleteErr
}
// 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
updateErr := r.Update(ctx, source)
if updateErr != nil {
logger.Error(updateErr, "failed to remove finalizer")
return ctrl.Result{}, updateErr
}
logger.Info("finalizer removed, resource can now be deleted")
}
@@ -162,21 +191,38 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
if !isEnabledForMirroring(sourceObj) {
// Resource is disabled - remove finalizer if present and delete all mirrors
if containsString(sourceObj.GetFinalizers(), constants.FinalizerName) {
if slices.Contains(sourceObj.GetFinalizers(), constants.FinalizerName) {
return r.handleDisabled(ctx, sourceObj)
}
// No finalizer, just skip
return ctrl.Result{}, nil
}
// Refuse to mirror sensitive Secret types (service-account tokens, bootstrap
// tokens, helm release blobs). Mirroring these to other namespaces is a
// credential exposure path. If a finalizer is already set (the resource was
// enabled before we started enforcing this list), tear down via handleDisabled
// so any prior mirrors are cleaned up.
if isBlacklistedSecret(sourceObj) {
secretType, _, _ := unstructured.NestedString(sourceObj.Object, "type")
logger.Info("refusing to mirror blacklisted Secret type",
"type", secretType,
"reason", "credential exposure risk")
if slices.Contains(sourceObj.GetFinalizers(), constants.FinalizerName) {
return r.handleDisabled(ctx, sourceObj)
}
return ctrl.Result{}, nil
}
// Add finalizer if not present
if !containsString(sourceObj.GetFinalizers(), constants.FinalizerName) {
if !slices.Contains(sourceObj.GetFinalizers(), constants.FinalizerName) {
logger.Info("adding finalizer to source resource")
finalizers := append(sourceObj.GetFinalizers(), constants.FinalizerName)
sourceObj.SetFinalizers(finalizers)
if err := r.Update(ctx, source); err != nil {
logger.Error(err, "failed to add finalizer")
return ctrl.Result{}, err
addFinalizerErr := r.Update(ctx, source)
if addFinalizerErr != nil {
logger.Error(addFinalizerErr, "failed to add finalizer")
return ctrl.Result{}, addFinalizerErr
}
logger.Info("finalizer added")
// Requeue to continue with reconciliation after finalizer is added
@@ -187,6 +233,9 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
targetNamespaces, err := r.resolveTargetNamespaces(ctx, sourceObj)
if err != nil {
logger.Error(err, "failed to resolve target namespaces")
if r.CircuitBreaker != nil {
r.CircuitBreaker.RecordFailure(req.Namespace, req.Name, r.GVK.Kind, err)
}
return ctrl.Result{}, err
}
@@ -200,8 +249,9 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
// Reconcile each target namespace
var reconciledCount, errorCount int
for _, targetNs := range targetNamespaces {
if err := r.reconcileMirror(ctx, source, sourceObj, targetNs); err != nil {
logger.Error(err, "failed to reconcile mirror", "targetNamespace", targetNs)
reconcileErr := r.reconcileMirror(ctx, source, sourceObj, targetNs)
if reconcileErr != nil {
logger.Error(reconcileErr, "failed to reconcile mirror", "targetNamespace", targetNs)
errorCount++
} else {
reconciledCount++
@@ -220,6 +270,9 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
// Update status annotation with last sync info
if err := r.updateLastSyncStatus(ctx, source, sourceObj, reconciledCount, errorCount); err != nil {
logger.Error(err, "failed to update sync status")
if r.CircuitBreaker != nil {
r.CircuitBreaker.RecordFailure(req.Namespace, req.Name, r.GVK.Kind, err)
}
return ctrl.Result{}, err
}
@@ -230,7 +283,22 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
// Return error if there were errors (controller-runtime will automatically requeue with exponential backoff)
if errorCount > 0 {
return ctrl.Result{}, fmt.Errorf("failed to reconcile %d/%d mirrors", errorCount, len(targetNamespaces))
err := fmt.Errorf("failed to reconcile %d/%d mirrors", errorCount, len(targetNamespaces))
// Record failure with circuit breaker
if r.CircuitBreaker != nil {
state, justOpened := r.CircuitBreaker.RecordFailure(req.Namespace, req.Name, r.GVK.Kind, err)
if justOpened {
logger.Info("circuit breaker opened due to repeated failures",
"state", state.String(),
"consecutiveFailures", r.CircuitBreaker.GetFailureCount(req.Namespace, req.Name, r.GVK.Kind))
}
}
return ctrl.Result{}, err
}
// Record success with circuit breaker
if r.CircuitBreaker != nil {
r.CircuitBreaker.RecordSuccess(req.Namespace, req.Name, r.GVK.Kind)
}
return ctrl.Result{}, nil
@@ -247,7 +315,7 @@ func (r *SourceReconciler) handleDisabled(ctx context.Context, sourceObj metav1.
}
// Remove finalizer if present
if containsString(sourceObj.GetFinalizers(), constants.FinalizerName) {
if slices.Contains(sourceObj.GetFinalizers(), constants.FinalizerName) {
logger.Info("removing finalizer from disabled resource")
finalizers := removeString(sourceObj.GetFinalizers(), constants.FinalizerName)
sourceObj.SetFinalizers(finalizers)
@@ -301,9 +369,9 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
}
// Check if update is needed
needsSync, err := hash.NeedsSync(source, existing, existing.GetAnnotations())
if err != nil {
return fmt.Errorf("failed to check if sync needed: %w", err)
needsSync, syncCheckErr := hash.NeedsSync(source, existing, existing.GetAnnotations())
if syncCheckErr != nil {
return fmt.Errorf("failed to check if sync needed: %w", syncCheckErr)
}
if !needsSync {
@@ -312,12 +380,14 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
}
// Update mirror
if err := UpdateMirror(existing, source); err != nil {
return fmt.Errorf("failed to update mirror: %w", err)
updateErr := UpdateMirror(existing, source)
if updateErr != nil {
return fmt.Errorf("failed to update mirror: %w", updateErr)
}
if err := r.Update(ctx, existing); err != nil {
return fmt.Errorf("failed to update mirror in cluster: %w", err)
clusterUpdateErr := r.Update(ctx, existing)
if clusterUpdateErr != nil {
return fmt.Errorf("failed to update mirror in cluster: %w", clusterUpdateErr)
}
logger.V(1).Info("mirror updated")
@@ -330,52 +400,83 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
return fmt.Errorf("failed to create mirror: %w", err)
}
if err := r.Create(ctx, mirror.(client.Object)); err != nil {
mirrorObj := mirror.(client.Object)
if err := r.Create(ctx, mirrorObj); err != nil {
return fmt.Errorf("failed to create mirror in cluster: %w", err)
}
logger.V(1).Info("mirror created")
// Verify mirror was actually created (catches webhook rejections, quota issues)
verifyMirror := &unstructured.Unstructured{}
verifyMirror.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
verifyKey := client.ObjectKey{Namespace: targetNs, Name: sourceObj.GetName()}
if verifyErr := r.Get(ctx, verifyKey, verifyMirror); verifyErr != nil {
logger.Error(verifyErr, "mirror creation verification failed - mirror may have been rejected")
return fmt.Errorf("mirror creation verification failed: %w", verifyErr)
}
logger.V(1).Info("mirror created and verified")
return nil
}
// deleteAllMirrors deletes all mirrors for a source resource.
// deleteAllMirrors deletes all mirrors that this source owns across the cluster.
// It verifies ownership (managed-by label + source-reference annotation) before
// deleting anything to avoid destroying unrelated resources that happen to share
// the source's name. Per-namespace failures are aggregated so callers can defer
// finalizer removal until cleanup actually succeeds.
func (r *SourceReconciler) deleteAllMirrors(ctx context.Context, sourceObj metav1.Object) error {
logger := log.FromContext(ctx)
// List all namespaces
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
if err != nil {
return fmt.Errorf("failed to list namespaces: %w", err)
}
// Get GVK from source object
sourceUnstructured, ok := sourceObj.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("source object is not unstructured")
}
var deleteCount int
var (
deleteCount int
deleteErrs []error
)
for _, ns := range allNamespaces {
// Skip source namespace
if ns == sourceObj.GetNamespace() {
continue
}
// Create mirror reference for deletion
mirror := &unstructured.Unstructured{}
mirror.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
mirror.SetNamespace(ns)
mirror.SetName(sourceObj.GetName())
err := r.Delete(ctx, mirror)
if err == nil {
deleteCount++
} else if !errors.IsNotFound(err) {
logger.Error(err, "failed to delete mirror", "namespace", ns)
existing := &unstructured.Unstructured{}
existing.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
getErr := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: sourceObj.GetName()}, existing)
if errors.IsNotFound(getErr) {
continue
}
if getErr != nil {
logger.Error(getErr, "failed to fetch potential mirror", "namespace", ns)
deleteErrs = append(deleteErrs, fmt.Errorf("get mirror %s/%s: %w", ns, sourceObj.GetName(), getErr))
continue
}
if !IsManagedByUs(existing) {
continue
}
srcNs, srcName, _, found := GetSourceReference(existing)
if !found || srcNs != sourceObj.GetNamespace() || srcName != sourceObj.GetName() {
continue
}
if delErr := r.Delete(ctx, existing); delErr != nil && !errors.IsNotFound(delErr) {
logger.Error(delErr, "failed to delete mirror", "namespace", ns)
deleteErrs = append(deleteErrs, fmt.Errorf("delete mirror %s/%s: %w", ns, sourceObj.GetName(), delErr))
continue
}
deleteCount++
}
logger.Info("deleted mirrors", "count", deleteCount)
logger.Info("deleted mirrors", "count", deleteCount, "errors", len(deleteErrs))
if len(deleteErrs) > 0 {
return stderrors.Join(deleteErrs...)
}
return nil
}
@@ -384,11 +485,12 @@ func (r *SourceReconciler) deleteAllMirrors(ctx context.Context, sourceObj metav
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)
// List all namespaces using unified method for consistency
nsInfo, err := r.NamespaceLister.ListNamespacesWithLabels(ctx)
if err != nil {
return 0, fmt.Errorf("failed to list namespaces: %w", err)
}
allNamespaces := nsInfo.All
// Get GVK from source object
sourceUnstructured, ok := sourceObj.(*unstructured.Unstructured)
@@ -472,30 +574,47 @@ func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceOb
return nil, nil
}
// Get all namespaces
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
// Validate patterns and log warnings for invalid ones
validationResults, allValid := filter.ValidatePatterns(patterns)
if !allValid {
logger := log.FromContext(ctx)
invalidPatterns := filter.InvalidPatterns(validationResults)
for _, invalid := range invalidPatterns {
logger.Info("invalid glob pattern in target-namespaces annotation, pattern will be skipped",
"pattern", invalid.Pattern,
"error", invalid.Error.Error(),
"source", sourceObj.GetName(),
"namespace", sourceObj.GetNamespace(),
)
}
// Filter to only valid patterns
var validPatterns []string
for _, result := range validationResults {
if result.Valid {
validPatterns = append(validPatterns, result.Pattern)
}
}
patterns = validPatterns
// If no valid patterns remain, return empty
if len(patterns) == 0 {
return nil, nil
}
}
// Get all namespace info in a single API call (more efficient than 3 separate calls)
nsInfo, err := r.NamespaceLister.ListNamespacesWithLabels(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
// Resolve target namespaces using the pre-categorized namespace info
targetNamespaces := filter.ResolveTargetNamespaces(
patterns,
allNamespaces,
allowMirrorsNamespaces,
optOutNamespaces,
nsInfo.All,
nsInfo.AllowMirrors,
nsInfo.OptOut,
sourceObj.GetNamespace(),
r.Filter,
)
@@ -508,20 +627,41 @@ func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceOb
return targetNamespaces, nil
}
// updateLastSyncStatus updates the source resource's annotations with sync status.
// updateLastSyncStatus updates the source resource's sync-status annotation.
// Skips the Update call entirely if the value is unchanged; otherwise the
// resulting watch event re-fires Reconcile and the controller spins on its
// own writes. This is a no-op for steady-state convergence.
func (r *SourceReconciler) updateLastSyncStatus(ctx context.Context, source runtime.Object, sourceObj metav1.Object, reconciledCount, errorCount int) error {
desired := fmt.Sprintf("reconciled:%d,errors:%d", reconciledCount, errorCount)
annotations := sourceObj.GetAnnotations()
if annotations[constants.AnnotationSyncStatus] == desired {
return nil
}
if annotations == nil {
annotations = make(map[string]string)
}
annotations[constants.AnnotationSyncStatus] = fmt.Sprintf("reconciled:%d,errors:%d", reconciledCount, errorCount)
annotations[constants.AnnotationSyncStatus] = desired
sourceObj.SetAnnotations(annotations)
// source (*unstructured.Unstructured) already implements client.Object
return r.Update(ctx, source.(*unstructured.Unstructured))
}
// isBlacklistedSecret reports whether the given source is a core/v1 Secret with
// a Type that must never be mirrored across namespaces.
func isBlacklistedSecret(obj *unstructured.Unstructured) bool {
if obj.GetKind() != "Secret" || obj.GetAPIVersion() != "v1" {
return false
}
secretType, found, err := unstructured.NestedString(obj.Object, "type")
if err != nil || !found {
return false
}
return slices.Contains(constants.BlacklistedSecretTypes, secretType)
}
// isEnabledForMirroring checks if a resource has both the label and annotation for mirroring.
func isEnabledForMirroring(obj metav1.Object) bool {
// Check label
@@ -539,15 +679,6 @@ func isEnabledForMirroring(obj metav1.Object) bool {
return true
}
// SetupWithManager sets up the controller with the Manager.
func (r *SourceReconciler) SetupWithManager(mgr ctrl.Manager) error {
// Build predicate to only watch resources with enabled label
// This reduces API server load by ~90%
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.Secret{}).
Complete(r)
}
// SetupWithManagerForResourceType sets up a controller for a specific resource type.
// This allows dynamic controller registration for any discovered resource type.
func (r *SourceReconciler) SetupWithManagerForResourceType(
@@ -611,16 +742,6 @@ 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))
+363 -14
View File
@@ -134,6 +134,14 @@ func (m *MockNamespaceLister) ListOptOutNamespaces(ctx context.Context) ([]strin
return args.Get(0).([]string), args.Error(1)
}
func (m *MockNamespaceLister) ListNamespacesWithLabels(ctx context.Context) (*NamespaceInfo, error) {
args := m.Called(ctx)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*NamespaceInfo), args.Error(1)
}
func TestIsEnabledForMirroring(t *testing.T) {
tests := []struct {
obj metav1.Object
@@ -280,9 +288,12 @@ func TestSourceReconciler_resolveTargetNamespaces(t *testing.T) {
mockLister := new(MockNamespaceLister)
if tt.expectListCalls {
mockLister.On("ListNamespaces", mock.Anything).Return(tt.allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(tt.allowMirrorsNamespaces, nil)
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
nsInfo := &NamespaceInfo{
All: tt.allNamespaces,
AllowMirrors: tt.allowMirrorsNamespaces,
OptOut: []string{},
}
mockLister.On("ListNamespacesWithLabels", mock.Anything).Return(nsInfo, nil)
}
r := &SourceReconciler{
@@ -442,12 +453,15 @@ func BenchmarkIsEnabledForMirroring(b *testing.B) {
func BenchmarkResolveTargetNamespaces(b *testing.B) {
mockLister := new(MockNamespaceLister)
allNamespaces := make([]string, 100)
for i := 0; i < 100; i++ {
for i := range 100 {
allNamespaces[i] = fmt.Sprintf("namespace-%d", i)
}
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(allNamespaces[:50], nil)
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
nsInfo := &NamespaceInfo{
All: allNamespaces,
AllowMirrors: allNamespaces[:50],
OptOut: []string{},
}
mockLister.On("ListNamespacesWithLabels", mock.Anything).Return(nsInfo, nil)
r := &SourceReconciler{
Config: &config.Config{},
@@ -517,7 +531,12 @@ func TestSourceReconciler_cleanupOrphanedMirrors(t *testing.T) {
// Setup: all namespaces in cluster
allNamespaces := []string{"default", "app-1", "app-2", "app-3", "prod-1"}
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
nsInfo := &NamespaceInfo{
All: allNamespaces,
AllowMirrors: []string{},
OptOut: []string{},
}
mockLister.On("ListNamespacesWithLabels", mock.Anything).Return(nsInfo, nil)
// Current target list (after annotation change): only app-1 and app-2
targetNamespaces := []string{"app-1", "app-2"}
@@ -624,14 +643,35 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
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)
nsInfo := &NamespaceInfo{
All: allNamespaces,
AllowMirrors: allowMirrorsNamespaces,
OptOut: []string{},
}
mockLister.On("ListNamespacesWithLabels", mock.Anything).Return(nsInfo, nil)
// Mock Get for source
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "test-secret"}, mock.Anything).
Return(nil, source)
// Helper to create a mock mirror for verification
createMirror := func(ns string) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]any{
"name": "test-secret",
"namespace": ns,
"labels": map[string]any{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
},
},
}
}
// 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).
@@ -639,12 +679,18 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
mockClient.On("Create", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
return obj.GetNamespace() == "app-1"
}), mock.Anything).Return(nil).Once()
// Verification Get after Create
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-1", Name: "test-secret"}, mock.Anything).
Return(nil, createMirror("app-1")).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()
// Verification Get after Create
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-2", Name: "test-secret"}, mock.Anything).
Return(nil, createMirror("app-2")).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).
@@ -751,9 +797,12 @@ func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T)
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)
nsInfo := &NamespaceInfo{
All: allNamespaces,
AllowMirrors: []string{},
OptOut: []string{},
}
mockLister.On("ListNamespacesWithLabels", mock.Anything).Return(nsInfo, nil)
// Mock Get for source
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "app-config"}, mock.Anything).
@@ -761,18 +810,42 @@ func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T)
notFoundErr := errors.NewNotFound(schema.GroupResource{Group: "", Resource: "configmaps"}, "app-config")
// Helper to create a mock mirror for verification
createMirror := func(ns string) *unstructured.Unstructured {
return &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]any{
"name": "app-config",
"namespace": ns,
"labels": map[string]any{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
},
},
}
}
// 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()
// Verification Get after Create
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-1", Name: "app-config"}, mock.Anything).
Return(nil, createMirror("prod-1")).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()
// Verification Get after Create
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-2", Name: "app-config"}, mock.Anything).
Return(nil, createMirror("prod-2")).Once()
// Mock cleanup: delete orphaned mirrors in app-1, app-2, app-3
for _, ns := range []string{"app-1", "app-2", "app-3"} {
@@ -806,3 +879,279 @@ func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T)
mockClient.AssertExpectations(t)
mockLister.AssertExpectations(t)
}
func TestSourceReconciler_deleteAllMirrors_skipsUnmanagedResources(t *testing.T) {
// Regression test: deleteAllMirrors must NOT delete a resource it does not own,
// even if the name and GVK happen to match the source.
sourceObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "shared-name",
"namespace": "default",
"uid": "source-uid",
},
},
}
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
mockLister.On("ListNamespaces", mock.Anything).Return([]string{"default", "ns-other"}, nil)
// In ns-other a resource with the same name/GVK exists but is NOT managed
// by kubemirror — pretend it's a regular Secret created by another operator.
otherSecret := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "shared-name",
"namespace": "ns-other",
},
},
}
mockClient.On("Get", mock.Anything,
types.NamespacedName{Namespace: "ns-other", Name: "shared-name"},
mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, otherSecret)
r := &SourceReconciler{Client: mockClient, NamespaceLister: mockLister}
err := r.deleteAllMirrors(context.Background(), sourceObj)
require.NoError(t, err)
// The critical assertion: Delete was NEVER called.
mockClient.AssertNotCalled(t, "Delete", mock.Anything, mock.Anything, mock.Anything)
mockLister.AssertExpectations(t)
}
func TestSourceReconciler_deleteAllMirrors_aggregatesErrors(t *testing.T) {
// Regression test: per-namespace deletion failures must be returned (joined),
// otherwise callers will remove the finalizer and orphan the failed mirrors.
sourceObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "default",
"uid": "source-uid",
},
},
}
managedMirror := 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",
},
},
},
}
}
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
mockLister.On("ListNamespaces", mock.Anything).Return([]string{"default", "ns-ok", "ns-fail"}, nil)
mockClient.On("Get", mock.Anything,
types.NamespacedName{Namespace: "ns-ok", Name: "test-secret"},
mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, managedMirror("ns-ok"))
mockClient.On("Get", mock.Anything,
types.NamespacedName{Namespace: "ns-fail", Name: "test-secret"},
mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, managedMirror("ns-fail"))
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
u, ok := obj.(*unstructured.Unstructured)
return ok && u.GetNamespace() == "ns-ok"
}), mock.Anything).Return(nil)
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
u, ok := obj.(*unstructured.Unstructured)
return ok && u.GetNamespace() == "ns-fail"
}), mock.Anything).Return(fmt.Errorf("webhook denied"))
r := &SourceReconciler{Client: mockClient, NamespaceLister: mockLister}
err := r.deleteAllMirrors(context.Background(), sourceObj)
require.Error(t, err, "must surface deletion failure so finalizer is retained")
assert.Contains(t, err.Error(), "ns-fail")
}
func TestIsBlacklistedSecret(t *testing.T) {
cases := []struct {
obj *unstructured.Unstructured
name string
expected bool
}{
{
name: "service-account-token blacklisted",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "Secret",
"type": "kubernetes.io/service-account-token",
}},
expected: true,
},
{
name: "bootstrap token blacklisted",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "Secret",
"type": "bootstrap.kubernetes.io/token",
}},
expected: true,
},
{
name: "helm release blacklisted",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "Secret",
"type": "helm.sh/release.v1",
}},
expected: true,
},
{
name: "opaque secret allowed",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "Secret",
"type": "Opaque",
}},
expected: false,
},
{
name: "secret without type allowed",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "Secret",
}},
expected: false,
},
{
name: "configmap with matching type ignored",
obj: &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1", "kind": "ConfigMap",
"type": "kubernetes.io/service-account-token",
}},
expected: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, isBlacklistedSecret(tc.obj))
})
}
}
func TestSourceReconciler_Reconcile_RefusesBlacklistedSecret(t *testing.T) {
// Regression test: enabling mirroring on a service-account-token Secret
// must NOT cause it to be mirrored anywhere.
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
tokenSecret := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "sa-token",
"namespace": "default",
"labels": map[string]interface{}{
constants.LabelEnabled: "true",
},
"annotations": map[string]interface{}{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "all",
},
},
"type": "kubernetes.io/service-account-token",
},
}
mockClient.On("Get", mock.Anything,
types.NamespacedName{Namespace: "default", Name: "sa-token"},
mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, tokenSecret)
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
}
result, err := r.Reconcile(context.Background(),
ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "sa-token"}})
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
// Critical: no namespace listing, no Create, no Update — the Secret was
// rejected before anything mirroring-related happened.
mockLister.AssertNotCalled(t, "ListNamespacesWithLabels", mock.Anything)
mockClient.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything)
}
func TestSourceReconciler_updateLastSyncStatus_skipsWhenUnchanged(t *testing.T) {
// Regression test: re-running with the same reconciled/error counts must
// NOT issue an Update call — otherwise every successful reconcile bumps
// resourceVersion, fires a watch event, and re-enters Reconcile in a loop.
mockClient := new(MockClient)
source := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test",
"namespace": "default",
"annotations": map[string]interface{}{
constants.AnnotationSyncStatus: "reconciled:3,errors:0",
},
},
},
}
r := &SourceReconciler{Client: mockClient}
err := r.updateLastSyncStatus(context.Background(), source, source, 3, 0)
require.NoError(t, err)
mockClient.AssertNotCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything)
}
func TestSourceReconciler_updateLastSyncStatus_writesWhenChanged(t *testing.T) {
mockClient := new(MockClient)
mockClient.On("Update", mock.Anything, mock.AnythingOfType("*unstructured.Unstructured"), mock.Anything).Return(nil)
source := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test",
"namespace": "default",
"annotations": map[string]interface{}{
constants.AnnotationSyncStatus: "reconciled:2,errors:0",
},
},
},
}
r := &SourceReconciler{Client: mockClient}
err := r.updateLastSyncStatus(context.Background(), source, source, 3, 0)
require.NoError(t, err)
assert.Equal(t, "reconciled:3,errors:0", source.GetAnnotations()[constants.AnnotationSyncStatus])
mockClient.AssertCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything)
}
+222 -1
View File
@@ -10,10 +10,13 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"github.com/lukaszraczylo/kubemirror/pkg/config"
)
var discoveryLog = ctrl.Log.WithName("discovery")
// ResourceDiscovery discovers all mirrorable resource types in a cluster.
type ResourceDiscovery struct {
discoveryClient discovery.DiscoveryInterface
@@ -34,6 +37,8 @@ func NewResourceDiscovery(cfg *rest.Config) (*ResourceDiscovery, error) {
// DiscoverMirrorableResources discovers all resource types that can be mirrored.
// It filters out resources that shouldn't be mirrored based on a deny list.
func (d *ResourceDiscovery) DiscoverMirrorableResources(ctx context.Context) ([]config.ResourceType, error) {
logger := discoveryLog.WithName("discover")
// Get all API resources in the cluster
_, apiResourceLists, err := d.discoveryClient.ServerGroupsAndResources()
if err != nil {
@@ -42,10 +47,12 @@ func (d *ResourceDiscovery) DiscoverMirrorableResources(ctx context.Context) ([]
if !discovery.IsGroupDiscoveryFailedError(err) {
return nil, fmt.Errorf("failed to discover API resources: %w", err)
}
logger.V(1).Info("some API groups had discovery errors, continuing with available resources")
}
var resources []config.ResourceType
seen := make(map[string]bool) // Deduplicate
var deniedCount int
for _, apiResourceList := range apiResourceLists {
gv, err := schema.ParseGroupVersion(apiResourceList.GroupVersion)
@@ -71,9 +78,23 @@ func (d *ResourceDiscovery) DiscoverMirrorableResources(ctx context.Context) ([]
// Skip denied resource types
if isDeniedResourceType(apiResource.Kind) {
deniedCount++
logger.V(2).Info("skipping denied resource type",
"kind", apiResource.Kind,
"group", gv.Group,
"version", gv.Version)
continue
}
// Warn about potentially high-cardinality resource types that aren't in deny list
if isHighCardinalityResource(apiResource.Kind) {
logger.Info("WARNING: discovered potentially high-cardinality resource type",
"kind", apiResource.Kind,
"group", gv.Group,
"version", gv.Version,
"recommendation", "Consider adding to deny list if high volume is observed")
}
rt := config.ResourceType{
Group: gv.Group,
Version: gv.Version,
@@ -91,6 +112,10 @@ func (d *ResourceDiscovery) DiscoverMirrorableResources(ctx context.Context) ([]
}
}
logger.Info("resource discovery complete",
"discovered", len(resources),
"denied", deniedCount)
return resources, nil
}
@@ -127,6 +152,7 @@ var deniedKinds = map[string]bool{
"ControllerRevision": true,
"PodMetrics": true,
"NodeMetrics": true,
"ReplicaSet": true, // Usually managed by Deployment
// Lease resources (used for leader election)
"Lease": true,
@@ -146,8 +172,203 @@ var deniedKinds = map[string]bool{
"APIService": true,
"ValidatingWebhookConfiguration": true,
"MutatingWebhookConfiguration": true,
}
// Storage resources - usually shouldn't be mirrored
"PersistentVolumeClaim": true,
"VolumeSnapshot": true,
"VolumeSnapshotContent": true,
// Longhorn resources - storage controller specific
"Engine": true,
"Replica": true,
"InstanceManager": true,
"ShareManager": true,
"BackingImageManager": true,
"BackingImageDataSource": true,
"Orphan": true,
"RecurringJob": true,
"EngineImage": true,
"BackingImage": true,
"BackupTarget": true,
"BackupVolume": true,
"Setting": true,
// ArgoCD/Argo resources - gitops/workflow specific
"Application": true,
"ApplicationSet": true,
"AppProject": true,
"Workflow": true,
"WorkflowTemplate": true,
"CronWorkflow": true,
"EventSource": true,
"EventBus": true,
"Sensor": true,
"AnalysisRun": true,
"AnalysisTemplate": true,
"Experiment": true,
"Rollout": true,
"WorkflowArtifactGCTask": true,
"WorkflowEventBinding": true,
"WorkflowTaskResult": true,
"WorkflowTaskSet": true,
// Cert-manager resources - certificate operator specific
"Certificate": true,
"CertificateRequest": true,
"Issuer": true,
"ClusterIssuer": true,
// External Secrets resources - secrets operator specific
"ExternalSecret": true,
"SecretStore": true,
"ClusterSecretStore": true,
"PushSecret": true,
// Generator resources
"ACRAccessToken": true,
"CloudsmithAccessToken": true,
"ECRAuthorizationToken": true,
"Fake": true,
"GCRAccessToken": true,
"GeneratorState": true,
"GithubAccessToken": true,
"Grafana": true,
"MFA": true,
"Password": true,
"QuayAccessToken": true,
"SSHKey": true,
"STSSessionToken": true,
"UUID": true,
"VaultDynamicSecret": true,
"Webhook": true,
// Kyverno resources - policy operator specific
"Policy": true,
"ClusterPolicy": true,
"PolicyException": true,
"NamespacedDeletingPolicy": true,
"NamespacedImageValidatingPolicy": true,
"NamespacedValidatingPolicy": true,
"CleanupPolicy": true,
"AdmissionReport": true,
"BackgroundScanReport": true,
"ClusterAdmissionReport": true,
"ClusterBackgroundScanReport": true,
"EphemeralReport": true,
"PolicyReport": true,
"UpdateRequest": true,
// Cilium resources - networking operator specific
"CiliumNetworkPolicy": true,
"CiliumClusterwideNetworkPolicy": true,
"CiliumEndpoint": true,
"CiliumIdentity": true,
"CiliumNode": true,
"CiliumExternalWorkload": true,
"CiliumLocalRedirectPolicy": true,
"CiliumEgressGatewayPolicy": true,
"CiliumGatewayClassConfig": true,
"CiliumNodeConfig": true,
"CiliumEnvoyConfig": true,
"CiliumClusterwideEnvoyConfig": true,
// Traefik Hub resources - API management specific
"API": true,
"APIAccess": true,
"APIAuth": true,
"APIBundle": true,
"APICatalogItem": true,
"APIPlan": true,
"APIPortal": true,
"APIPortalAuth": true,
"APIRateLimit": true,
"APIVersion": true,
"AIService": true,
"ManagedApplication": true,
"ManagedSubscription": true,
// Kong resources - API gateway specific
"KongConsumer": true,
"KongIngress": true,
"KongPlugin": true,
"KongClusterPlugin": true,
"KongUpstreamPolicy": true,
"KongConsumerGroup": true,
"TCPIngress": true,
"UDPIngress": true,
"IngressClassParameters": true,
// System Upgrade Controller
"Plan": true,
// Tor operator resources
"OnionService": true,
"OnionBalancedService": true,
"Tor": true,
// Gateway API resources - usually not mirrored
"Gateway": true,
"GatewayClass": true,
"HTTPRoute": true,
"TLSRoute": true,
"TCPRoute": true,
"UDPRoute": true,
"GRPCRoute": true,
"ReferenceGrant": true,
"BackendTLSPolicy": true,
// VictoriaMetrics operator resources
"VMAgent": true,
"VMAlert": true,
"VMAlertmanager": true,
"VMAlertmanagerConfig": true,
"VMAuth": true,
"VMCluster": true,
"VMNodeScrape": true,
"VMPodScrape": true,
"VMProbe": true,
"VMRule": true,
"VMServiceScrape": true,
"VMSingle": true,
"VMStaticScrape": true,
"VMScrapeConfig": true,
"VMUser": true,
"VMAnomaly": true,
// Jobs and workloads - usually shouldn't be mirrored
"Job": true,
"CronJob": true}
func isDeniedResourceType(kind string) bool {
return deniedKinds[kind]
}
// highCardinalityKinds are resource types that might generate high volumes of objects.
// These aren't denied by default but warrant monitoring when discovered.
var highCardinalityKinds = map[string]bool{
// Resources that might have many instances per namespace
"ServiceAccount": true, // Often auto-created per deployment
"Role": true, // Can be many per namespace
"RoleBinding": true, // Can be many per namespace
"NetworkPolicy": true, // Can be many per namespace
"LimitRange": true, // Usually few but triggers on all namespace changes
"ResourceQuota": true, // Usually few but triggers on all namespace changes
"HorizontalPodAutoscaler": true, // One per deployment/statefulset
// CRD resources that might have high cardinality
"ServiceEntry": true, // Istio - can have many
"VirtualService": true, // Istio - can have many
"DestinationRule": true, // Istio - can have many
"EnvoyFilter": true, // Istio - can have many
"Sidecar": true, // Istio - can have many
"PeerAuthentication": true, // Istio - can have many
// Prometheus-style monitoring resources
"ServiceMonitor": true, // Often one per service
"PodMonitor": true, // Often one per pod type
"PrometheusRule": true, // Can have many rules
}
// isHighCardinalityResource checks if a resource type might generate high volumes.
func isHighCardinalityResource(kind string) bool {
return highCardinalityKinds[kind]
}
+31 -1
View File
@@ -67,6 +67,7 @@ func TestIsDeniedResourceType(t *testing.T) {
{name: "Lease", kind: "Lease", want: true},
{name: "Namespace", kind: "Namespace", want: true},
{name: "ClusterRole", kind: "ClusterRole", want: true},
{name: "Certificate", kind: "Certificate", want: true}, // cert-manager resources are denied
// Should NOT be denied
{name: "Secret", kind: "Secret", want: false},
@@ -76,7 +77,6 @@ func TestIsDeniedResourceType(t *testing.T) {
{name: "Deployment", kind: "Deployment", want: false},
{name: "StatefulSet", kind: "StatefulSet", want: false},
{name: "Middleware", kind: "Middleware", want: false},
{name: "Certificate", kind: "Certificate", want: false},
}
for _, tt := range tests {
@@ -86,3 +86,33 @@ func TestIsDeniedResourceType(t *testing.T) {
})
}
}
func TestIsHighCardinalityResource(t *testing.T) {
tests := []struct {
name string
kind string
want bool
}{
// High cardinality resources (should warn)
{name: "ServiceAccount", kind: "ServiceAccount", want: true},
{name: "Role", kind: "Role", want: true},
{name: "RoleBinding", kind: "RoleBinding", want: true},
{name: "NetworkPolicy", kind: "NetworkPolicy", want: true},
{name: "ServiceMonitor", kind: "ServiceMonitor", want: true},
{name: "VirtualService", kind: "VirtualService", want: true},
// Not high cardinality (no warning needed)
{name: "Secret", kind: "Secret", want: false},
{name: "ConfigMap", kind: "ConfigMap", want: false},
{name: "Service", kind: "Service", want: false},
{name: "Deployment", kind: "Deployment", want: false},
{name: "Middleware", kind: "Middleware", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isHighCardinalityResource(tt.kind)
assert.Equal(t, tt.want, got, "isHighCardinalityResource(%s) = %v, want %v", tt.kind, got, tt.want)
})
}
}
+75 -3
View File
@@ -2,12 +2,79 @@
package filter
import (
"fmt"
"path/filepath"
"strings"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
)
// PatternValidationResult contains the result of validating a pattern.
type PatternValidationResult struct {
Error error
Pattern string
Valid bool
}
// ValidatePattern checks if a glob pattern is syntactically valid.
// Returns an error if the pattern cannot be compiled by filepath.Match.
func ValidatePattern(pattern string) error {
// Empty pattern is invalid
if pattern == "" {
return fmt.Errorf("empty pattern")
}
// Special keywords are always valid
if pattern == constants.TargetNamespacesAll || pattern == constants.TargetNamespacesAllLabeled {
return nil
}
// Use filepath.Match with a test string to validate pattern syntax
// We use "test" as a dummy value - we only care about the error
_, err := filepath.Match(pattern, "test")
if err != nil {
return fmt.Errorf("invalid glob pattern %q: %w", pattern, err)
}
return nil
}
// ValidatePatterns validates a list of patterns and returns results for each.
// Returns a slice of validation results and a boolean indicating if all patterns are valid.
func ValidatePatterns(patterns []string) ([]PatternValidationResult, bool) {
if len(patterns) == 0 {
return nil, true
}
results := make([]PatternValidationResult, len(patterns))
allValid := true
for i, pattern := range patterns {
err := ValidatePattern(pattern)
results[i] = PatternValidationResult{
Pattern: pattern,
Valid: err == nil,
Error: err,
}
if err != nil {
allValid = false
}
}
return results, allValid
}
// InvalidPatterns returns only the invalid patterns from a validation result.
func InvalidPatterns(results []PatternValidationResult) []PatternValidationResult {
var invalid []PatternValidationResult
for _, r := range results {
if !r.Valid {
invalid = append(invalid, r)
}
}
return invalid
}
// NamespaceFilter handles namespace filtering logic including patterns and exclusions.
type NamespaceFilter struct {
excludedNamespaces map[string]bool
@@ -155,14 +222,19 @@ func ResolveTargetNamespaces(
default:
// Check if it's a pattern or direct namespace name
if strings.Contains(pattern, "*") || strings.Contains(pattern, "?") {
// It's a glob pattern - match against all namespaces
// It's a glob pattern - match against all namespaces. Honor
// opt-out namespaces (allow-mirrors=false) the same way the
// "all" case does — otherwise an opt-out namespace can still
// receive a mirror via a glob like "app-*".
for _, ns := range allNamespaces {
if matchesPattern(ns, pattern) && ns != sourceNamespace && filter.IsAllowed(ns) {
if matchesPattern(ns, pattern) && ns != sourceNamespace && filter.IsAllowed(ns) && !optOutMap[ns] {
targetMap[ns] = true
}
}
} else {
// Direct namespace name
// Direct namespace name. Explicit listing is treated as
// intentional opt-in by the source author and bypasses the
// opt-out guard (matches prior behavior).
if pattern != sourceNamespace && filter.IsAllowed(pattern) {
targetMap[pattern] = true
}
+181
View File
@@ -376,6 +376,23 @@ func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
// Only "specific-ns" would be allowed, but it's not in allNamespaces
assert.Empty(t, got)
})
t.Run("glob pattern honors opt-out namespaces", func(t *testing.T) {
// Regression: a namespace with allow-mirrors=false used to receive a
// mirror via a glob like "app-*" because the glob branch ignored the
// opt-out list (only the "all" branch checked it).
got := ResolveTargetNamespaces(
[]string{"app-*"},
[]string{"app-1", "app-2", "app-optout"},
[]string{},
[]string{"app-optout"},
"default",
NewNamespaceFilter([]string{}, []string{}),
)
assert.Contains(t, got, "app-1")
assert.Contains(t, got, "app-2")
assert.NotContains(t, got, "app-optout", "opt-out namespace must not receive mirrors via glob match")
})
}
// Benchmark tests for critical paths
@@ -592,3 +609,167 @@ func BenchmarkResolveTargetNamespaces_LargeScale(b *testing.B) {
}
})
}
// Tests for pattern validation
func TestValidatePattern(t *testing.T) {
tests := []struct {
name string
pattern string
wantErr bool
}{
{
name: "valid simple pattern",
pattern: "app-*",
wantErr: false,
},
{
name: "valid complex pattern",
pattern: "*-app-*-db",
wantErr: false,
},
{
name: "valid exact match",
pattern: "my-namespace",
wantErr: false,
},
{
name: "valid question mark pattern",
pattern: "app-?",
wantErr: false,
},
{
name: "valid character class pattern",
pattern: "app-[abc]",
wantErr: false,
},
{
name: "valid 'all' keyword",
pattern: constants.TargetNamespacesAll,
wantErr: false,
},
{
name: "valid 'all-labeled' keyword",
pattern: constants.TargetNamespacesAllLabeled,
wantErr: false,
},
{
name: "invalid unclosed bracket",
pattern: "app-[",
wantErr: true,
},
{
name: "character range pattern is valid",
pattern: "app-[z-a]",
wantErr: false, // filepath.Match accepts character ranges
},
{
name: "empty pattern is invalid",
pattern: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePattern(tt.pattern)
if tt.wantErr {
assert.Error(t, err, "expected error for pattern %q", tt.pattern)
} else {
assert.NoError(t, err, "unexpected error for pattern %q", tt.pattern)
}
})
}
}
func TestValidatePatterns(t *testing.T) {
tests := []struct {
name string
patterns []string
wantAllValid bool
wantInvalid int
}{
{
name: "all valid patterns",
patterns: []string{"app-*", "prod-*", "staging-db"},
wantAllValid: true,
wantInvalid: 0,
},
{
name: "empty patterns list",
patterns: []string{},
wantAllValid: true,
wantInvalid: 0,
},
{
name: "one invalid pattern",
patterns: []string{"app-*", "invalid-[", "prod-*"},
wantAllValid: false,
wantInvalid: 1,
},
{
name: "multiple invalid patterns",
patterns: []string{"invalid-[", "app-*", "bad-["},
wantAllValid: false,
wantInvalid: 2,
},
{
name: "all invalid patterns",
patterns: []string{"bad-[", "worse-["},
wantAllValid: false,
wantInvalid: 2,
},
{
name: "mixed with keywords",
patterns: []string{constants.TargetNamespacesAll, "bad-[", "app-*"},
wantAllValid: false,
wantInvalid: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results, allValid := ValidatePatterns(tt.patterns)
assert.Equal(t, tt.wantAllValid, allValid, "allValid mismatch")
invalidPatterns := InvalidPatterns(results)
assert.Equal(t, tt.wantInvalid, len(invalidPatterns), "invalid count mismatch")
// Verify all invalid patterns have errors
for _, invalid := range invalidPatterns {
assert.False(t, invalid.Valid)
assert.NotNil(t, invalid.Error)
}
})
}
}
func TestInvalidPatterns(t *testing.T) {
t.Run("filters only invalid patterns", func(t *testing.T) {
results := []PatternValidationResult{
{Pattern: "app-*", Valid: true, Error: nil},
{Pattern: "bad-[", Valid: false, Error: fmt.Errorf("invalid")},
{Pattern: "prod-*", Valid: true, Error: nil},
{Pattern: "worse-[", Valid: false, Error: fmt.Errorf("invalid")},
}
invalid := InvalidPatterns(results)
assert.Len(t, invalid, 2)
assert.Equal(t, "bad-[", invalid[0].Pattern)
assert.Equal(t, "worse-[", invalid[1].Pattern)
})
t.Run("returns nil for empty input", func(t *testing.T) {
invalid := InvalidPatterns(nil)
assert.Nil(t, invalid)
})
t.Run("returns nil for all valid patterns", func(t *testing.T) {
results := []PatternValidationResult{
{Pattern: "app-*", Valid: true, Error: nil},
{Pattern: "prod-*", Valid: true, Error: nil},
}
invalid := InvalidPatterns(results)
assert.Nil(t, invalid)
})
}
+46 -25
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strings"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -85,50 +86,70 @@ func extractConfigMapContent(cm *corev1.ConfigMap) map[string]interface{} {
}
// extractUnstructuredContent extracts content from an unstructured resource (CRDs, etc.).
//
// Hashes every non-Kubernetes-managed field at the top level — not only spec
// — so resources with both spec and data (e.g. an unstructured Secret/CM, or
// a CRD using a custom schema) detect drift on every content field, matching
// the fields that updateUnstructuredMirror copies to the mirror.
//
// When a transform annotation is present the source's labels and annotations
// are also folded into the hash, because templates can read them via
// TransformContext.Labels / .Annotations and a label change would otherwise
// be invisible to NeedsSync.
func extractUnstructuredContent(obj runtime.Object) (interface{}, error) {
// Convert to unstructured
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err != nil {
return nil, fmt.Errorf("failed to convert to unstructured: %w", err)
}
u := &unstructured.Unstructured{Object: unstructuredObj}
// Make a deep copy to avoid race conditions when accessing nested fields
// NestedMap modifies the underlying map, so we need our own copy
uCopy := u.DeepCopy()
// (NestedMap may modify the underlying map).
u := (&unstructured.Unstructured{Object: unstructuredObj}).DeepCopy()
// Extract spec (most resources have spec)
spec, found, err := unstructured.NestedMap(uCopy.Object, "spec")
if err != nil {
return nil, fmt.Errorf("failed to extract spec: %w", err)
skipFields := map[string]bool{
"metadata": true,
"status": true,
"apiVersion": true,
"kind": true,
}
content := make(map[string]interface{})
if found {
content["spec"] = spec
}
// For resources without spec, include all fields except metadata and status
if !found {
for key, value := range uCopy.Object {
if key != "metadata" && key != "status" && key != "apiVersion" && key != "kind" {
content[key] = value
}
for key, value := range u.Object {
if !skipFields[key] {
content[key] = value
}
}
// 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
}
annotations := u.GetAnnotations()
if transform, exists := annotations[constants.AnnotationTransform]; exists && transform != "" {
content["transform"] = transform
// Templates can read source labels and annotations; include them so a
// label/annotation change triggers re-render of transformed mirrors.
// Filter out the kubemirror.raczylo.com/* keys to avoid the source's
// own bookkeeping (sync-status annotation, etc.) churning the hash.
content["sourceLabels"] = filterKubeMirror(u.GetLabels())
content["sourceAnnotations"] = filterKubeMirror(annotations)
}
return content, nil
}
// filterKubeMirror returns a copy of m with all kubemirror.raczylo.com/* keys
// removed. Used to exclude controller-managed keys from content hashing so
// the controller's own writes don't churn the hash.
func filterKubeMirror(m map[string]string) map[string]string {
if len(m) == 0 {
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
if !strings.HasPrefix(k, constants.Domain+"/") {
out[k] = v
}
}
return out
}
// NeedsSync determines if a target resource needs to be synced based on content changes.
// It uses a multi-layer strategy:
// 1. Check generation field (if available) - fastest
+188 -2
View File
@@ -168,12 +168,12 @@ func TestComputeContentHash_ConfigMap(t *testing.T) {
name: "binaryData included in hash",
cm1: &corev1.ConfigMap{
BinaryData: map[string][]byte{
"file": []byte{0x00, 0x01, 0x02},
"file": {0x00, 0x01, 0x02},
},
},
cm2: &corev1.ConfigMap{
BinaryData: map[string][]byte{
"file": []byte{0x00, 0x01, 0xFF},
"file": {0x00, 0x01, 0xFF},
},
},
wantSame: false,
@@ -484,6 +484,119 @@ func mustComputeHash(t *testing.T, obj runtime.Object) string {
return hash
}
// TestComputeContentHash_NoMutation verifies that hash computation doesn't mutate the input object.
// This is critical because NestedMap can modify the underlying map.
func TestComputeContentHash_NoMutation(t *testing.T) {
t.Run("unstructured object is not mutated", func(t *testing.T) {
// Create an unstructured object with nested spec
original := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Custom",
"metadata": map[string]interface{}{
"name": "test-resource",
"namespace": "default",
"annotations": map[string]interface{}{
constants.AnnotationTransform: `{"rules":[{"field":"spec.value","action":"base64encode"}]}`,
},
},
"spec": map[string]interface{}{
"field1": "value1",
"nested": map[string]interface{}{
"deep": "data",
},
},
"status": map[string]interface{}{
"condition": "Ready",
},
},
}
// Deep copy the original to compare after hash computation
expectedCopy := original.DeepCopy()
// Compute hash multiple times
hash1, err := ComputeContentHash(original)
require.NoError(t, err)
hash2, err := ComputeContentHash(original)
require.NoError(t, err)
// Hashes should be consistent (object wasn't modified)
assert.Equal(t, hash1, hash2, "hash should be consistent across calls")
// Original object should be unchanged
assert.Equal(t, expectedCopy.Object, original.Object, "original object should not be mutated")
})
t.Run("secret is not mutated", func(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
Annotations: map[string]string{
constants.AnnotationTransform: `{"rules":[]}`,
},
},
Data: map[string][]byte{
"password": []byte("secret123"),
},
Type: corev1.SecretTypeOpaque,
}
// Copy for comparison
originalData := make(map[string][]byte)
for k, v := range secret.Data {
originalData[k] = append([]byte(nil), v...)
}
originalAnnotations := make(map[string]string)
for k, v := range secret.Annotations {
originalAnnotations[k] = v
}
// Compute hash
_, err := ComputeContentHash(secret)
require.NoError(t, err)
// Verify no mutation
assert.Equal(t, originalData, secret.Data, "secret data should not be mutated")
assert.Equal(t, originalAnnotations, secret.Annotations, "secret annotations should not be mutated")
})
t.Run("configmap is not mutated", func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
Data: map[string]string{
"config.yaml": "key: value",
},
BinaryData: map[string][]byte{
"binary": {0x00, 0x01, 0x02},
},
}
// Copy for comparison
originalData := make(map[string]string)
for k, v := range cm.Data {
originalData[k] = v
}
originalBinaryData := make(map[string][]byte)
for k, v := range cm.BinaryData {
originalBinaryData[k] = append([]byte(nil), v...)
}
// Compute hash
_, err := ComputeContentHash(cm)
require.NoError(t, err)
// Verify no mutation
assert.Equal(t, originalData, cm.Data, "configmap data should not be mutated")
assert.Equal(t, originalBinaryData, cm.BinaryData, "configmap binary data should not be mutated")
})
}
// Benchmark tests
func BenchmarkComputeContentHash_Secret(b *testing.B) {
secret := &corev1.Secret{
@@ -528,3 +641,76 @@ func BenchmarkNeedsSync(b *testing.B) {
_, _ = NeedsSync(source, target, annotations)
}
}
func TestComputeContentHash_Unstructured_HashesAllNonMetaFields(t *testing.T) {
// Regression (M7): the previous implementation only hashed `spec` when it
// was present, dropping any other top-level content (data, type, custom
// CRD fields). Drift to those fields was invisible until the next resync.
objSpecOnly := &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Custom",
"spec": map[string]interface{}{"field": "v1"},
"data": map[string]interface{}{"k": "v1"},
}}
objSpecAndDifferentData := &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Custom",
"spec": map[string]interface{}{"field": "v1"},
"data": map[string]interface{}{"k": "v2"}, // only data differs
}}
h1, err := ComputeContentHash(objSpecOnly)
require.NoError(t, err)
h2, err := ComputeContentHash(objSpecAndDifferentData)
require.NoError(t, err)
assert.NotEqual(t, h1, h2, "data field must contribute to hash even when spec exists")
}
func TestComputeContentHash_Unstructured_TransformIncludesLabelsAndAnnotations(t *testing.T) {
// Regression (M6): templates can read source labels/annotations via
// TransformContext. When a transform annotation is present, label /
// annotation changes must therefore re-hash so NeedsSync re-renders.
make := func(label, annot string) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"labels": map[string]interface{}{"app": label},
"annotations": map[string]interface{}{constants.AnnotationTransform: "rules: []", "tier": annot},
},
"data": map[string]interface{}{"k": "v"},
}}
}
base, err := ComputeContentHash(make("v1", "prod"))
require.NoError(t, err)
labelChanged, err := ComputeContentHash(make("v2", "prod"))
require.NoError(t, err)
assert.NotEqual(t, base, labelChanged, "label change must re-hash when transform is present")
annotChanged, err := ComputeContentHash(make("v1", "stage"))
require.NoError(t, err)
assert.NotEqual(t, base, annotChanged, "annotation change must re-hash when transform is present")
}
func TestComputeContentHash_Unstructured_LabelChangesIgnoredWithoutTransform(t *testing.T) {
// Counterpart to the above: when there is NO transform annotation, label
// changes must NOT churn the hash — that would cause unnecessary mirror
// re-writes for plain (non-transformed) mirrors.
make := func(label string) *unstructured.Unstructured {
return &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": map[string]interface{}{
"labels": map[string]interface{}{"app": label},
},
"data": map[string]interface{}{"k": "v"},
}}
}
h1, err := ComputeContentHash(make("v1"))
require.NoError(t, err)
h2, err := ComputeContentHash(make("v2"))
require.NoError(t, err)
assert.Equal(t, h1, h2, "label changes must not re-hash without a transform annotation")
}
+25
View File
@@ -15,6 +15,18 @@ import (
"github.com/lukaszraczylo/kubemirror/pkg/constants"
)
// maxConcurrentTemplateExecutions caps the number of in-flight template
// executions across the process. text/template.Execute is not context-aware,
// so when applyTemplateRule times out the executor goroutine continues to
// run until the template returns on its own. This semaphore bounds the
// damage from a pathological template (e.g. {{ range }} that never
// terminates): once the cap is hit, applyTemplateRule fails fast instead
// of leaking another runaway goroutine. The cap is intentionally generous
// — normal workloads should never approach it.
const maxConcurrentTemplateExecutions = 64
var templateExecSemaphore = make(chan struct{}, maxConcurrentTemplateExecutions)
// Transformer applies transformation rules to Kubernetes resources.
type Transformer struct {
options TransformOptions
@@ -168,6 +180,19 @@ func (t *Transformer) applyTemplateRule(u *unstructured.Unstructured, rule Rule,
return fmt.Errorf("failed to parse template: %w", err)
}
// Acquire a slot in the global template-execution semaphore. If saturated,
// fail fast rather than spawning yet another goroutine that may leak when
// it times out (text/template is not context-aware so timed-out goroutines
// continue running until the template returns).
select {
case templateExecSemaphore <- struct{}{}:
defer func() { <-templateExecSemaphore }()
default:
return fmt.Errorf("template execution rejected: %d concurrent executions in flight, "+
"likely indicates one or more runaway templates leaking goroutines",
maxConcurrentTemplateExecutions)
}
// Execute template with timeout
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), t.options.TemplateTimeout)
defer cancel()
+46 -8
View File
@@ -15,13 +15,13 @@ import (
func TestTransformer_Transform(t *testing.T) {
tests := []struct {
name string
source runtime.Object
ctx TransformContext
source runtime.Object
validate func(t *testing.T, result runtime.Object)
name string
errMsg string
options TransformOptions
wantErr bool
errMsg string
validate func(t *testing.T, result runtime.Object)
}{
// Good cases - Value rules
{
@@ -566,12 +566,12 @@ func TestParsePath(t *testing.T) {
func TestSetNestedField(t *testing.T) {
tests := []struct {
name string
obj map[string]interface{}
path []string
value interface{}
wantErr bool
obj map[string]interface{}
want map[string]interface{}
name string
path []string
wantErr bool
}{
{
name: "set top-level field",
@@ -734,6 +734,44 @@ func TestTransformer_TemplateTimeout(t *testing.T) {
t.Skip("Template timeout testing is unreliable in unit tests - covered by integration tests")
}
func TestTransformer_TemplateConcurrencyCap(t *testing.T) {
// Regression (H3): text/template.Execute is not context-aware, so a
// timed-out template execution leaves its goroutine running until the
// template returns on its own. We bound that by a global semaphore;
// when saturated, applyTemplateRule must fail fast instead of spawning
// another goroutine.
//
// This test saturates the semaphore directly, then asserts the next
// call returns the cap-exceeded error rather than blocking or panicking.
for i := 0; i < maxConcurrentTemplateExecutions; i++ {
templateExecSemaphore <- struct{}{}
}
defer func() {
// Drain whatever the test left in the semaphore so subsequent tests
// see a clean state.
for {
select {
case <-templateExecSemaphore:
default:
return
}
}
}()
tmpl := "hello"
tr := NewDefaultTransformer()
rule := Rule{Path: "data.greeting", Template: &tmpl}
u := &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"data": map[string]interface{}{},
}}
err := tr.applyTemplateRule(u, rule, TransformContext{})
require.Error(t, err)
assert.Contains(t, err.Error(), "rejected", "saturated semaphore must reject new template executions")
}
func TestMatchGlob(t *testing.T) {
tests := []struct {
name string
+10 -34
View File
@@ -13,46 +13,22 @@ type TransformRules struct {
// Rule represents a single transformation rule.
type Rule struct {
// Path is the JSONPath to the field to transform (e.g., "data.LOG_LEVEL", "metadata.labels.env")
Path string `yaml:"path"`
// Value sets a static value (mutually exclusive with Template, Merge, Delete)
Value *string `yaml:"value,omitempty"`
// Template uses Go templates to generate the value (mutually exclusive with Value, Merge, Delete)
Template *string `yaml:"template,omitempty"`
// Merge merges a map into the target field (mutually exclusive with Value, Template, Delete)
Merge map[string]interface{} `yaml:"merge,omitempty"`
// Delete removes the field (mutually exclusive with Value, Template, Merge)
Delete bool `yaml:"delete,omitempty"`
// NamespacePattern is an optional glob pattern that limits this rule to specific target namespaces
// Examples: "prod-*", "*-staging", "preprod-*"
// If not specified, the rule applies to all namespaces
NamespacePattern *string `yaml:"namespacePattern,omitempty"`
Value *string `yaml:"value,omitempty"`
Template *string `yaml:"template,omitempty"`
Merge map[string]interface{} `yaml:"merge,omitempty"`
NamespacePattern *string `yaml:"namespacePattern,omitempty"`
Path string `yaml:"path"`
Delete bool `yaml:"delete,omitempty"`
}
// TransformContext provides context variables for template evaluation.
type TransformContext struct {
// TargetNamespace is the namespace where the mirror is being created
Labels map[string]string
Annotations map[string]string
TargetNamespace string
// SourceNamespace is the namespace of the source resource
SourceNamespace string
// SourceName is the name of the source resource
SourceName string
// TargetName is the name of the target resource (usually same as source)
TargetName string
// Labels is a copy of the source resource's labels
Labels map[string]string
// Annotations is a copy of the source resource's annotations
Annotations map[string]string
SourceName string
TargetName string
}
// TransformOptions configures the transformation behavior.
+2 -2
View File
@@ -10,9 +10,9 @@ import (
func TestRule_Validate(t *testing.T) {
tests := []struct {
name string
errMsg string
rule Rule
wantErr bool
errMsg string
}{
// Good cases
{
@@ -191,8 +191,8 @@ func TestRule_Type(t *testing.T) {
func TestRuleType_String(t *testing.T) {
tests := []struct {
name string
ruleType RuleType
want string
ruleType RuleType
}{
{name: "value", ruleType: RuleTypeValue, want: "value"},
{name: "template", ruleType: RuleTypeTemplate, want: "template"},