mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-07-22 21:49:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
441b05def5 | ||
|
|
4c0af68f60 | ||
|
|
a0e37ec5d8 | ||
|
|
e0ff007e2a | ||
|
|
390e199ae0 | ||
|
|
5919e14c18 | ||
|
|
fc97f34cea | ||
|
|
fb1fa9e255 | ||
|
|
ebb2365a88 | ||
|
|
3b9637bd37 | ||
|
|
ae90f09aef | ||
|
|
8bcce1bff8 | ||
|
|
7ce5b71406 | ||
|
|
3a713a6388 | ||
|
|
8adec76ce8 | ||
|
|
6a41222b34 | ||
|
|
28896ab1d6 | ||
|
|
6d27d7dfe7 | ||
|
|
330666336f | ||
|
|
3628929871 | ||
|
|
c8ca8c41b6 | ||
|
|
e5aa084c14 | ||
|
|
417258630a | ||
|
|
778be24c13 | ||
|
|
d2b8ab4a2d | ||
|
|
ea71c1e839 | ||
|
|
2fc7d23d40 | ||
|
|
557c6cfc9b | ||
|
|
3179a25e41 | ||
|
|
711713bf76 | ||
|
|
0e8a9fd0a0 | ||
|
|
d97132a111 | ||
|
|
3ab86c34b6 | ||
|
|
76237c6bf6 | ||
|
|
d552e45e2f | ||
|
|
1836381d9d | ||
|
|
9240167386 | ||
|
|
5e360d80c6 | ||
|
|
e5a0846165 | ||
|
|
64b14b84e0 | ||
|
|
2a1575f5c9 | ||
|
|
8feb93146b | ||
|
|
d055f11bd4 | ||
|
|
e886143429 | ||
|
|
ba0797f5af | ||
|
|
75f7c18f3c | ||
|
|
cf095e93f4 | ||
|
|
a8e48a9eb6 | ||
|
|
dfe08b35d1 | ||
|
|
99c0eccd53 | ||
|
|
4277c8ac39 | ||
|
|
b555d84d32 | ||
|
|
30ffc823d9 | ||
|
|
c3450e2af2 | ||
|
|
d0857e4f4a | ||
|
|
36c9e859af | ||
|
|
05aacddeab | ||
|
|
a9838e9156 | ||
|
|
317143c458 | ||
|
|
42358a3743 | ||
|
|
f2ecc5d56a | ||
|
|
30de592c0c | ||
|
|
ea405f44a2 | ||
|
|
f62bec57f5 | ||
|
|
c9cf81e4fd | ||
|
|
7c8a12958d | ||
|
|
0bac0f4645 | ||
|
|
afe6602ddd | ||
|
|
91a1d05a92 | ||
|
|
3c7f25bc16 | ||
|
|
1a40783e36 | ||
|
|
56809700fb | ||
|
|
5db9fa8653 | ||
|
|
1e4af7df0c | ||
|
|
668a84b070 | ||
|
|
9dea7a1022 | ||
|
|
ba95e09f5c | ||
|
|
53afeb8560 | ||
|
|
096dca47d1 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -51,7 +51,8 @@ archives:
|
||||
- examples/*
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats:
|
||||
- zip
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
|
||||
@@ -271,6 +271,34 @@ metadata:
|
||||
kubemirror.raczylo.com/allow-mirrors: "true"
|
||||
```
|
||||
|
||||
### Pause or Exclude a Resource
|
||||
|
||||
Two annotations control opting a source out of mirroring:
|
||||
|
||||
- `kubemirror.raczylo.com/exclude: "true"` — opt the resource out entirely. It is
|
||||
not mirrored, and any mirrors it previously created are **deleted**.
|
||||
- `kubemirror.raczylo.com/paused: "true"` — **freeze** the resource. Existing
|
||||
mirrors are left exactly as they are (no updates, no cleanup) until the
|
||||
annotation is removed. Useful during maintenance when you want mirrors to
|
||||
persist but stop tracking source changes.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: shared-credentials
|
||||
namespace: default
|
||||
labels:
|
||||
kubemirror.raczylo.com/enabled: "true"
|
||||
annotations:
|
||||
kubemirror.raczylo.com/sync: "true"
|
||||
kubemirror.raczylo.com/target-namespaces: "all"
|
||||
# Freeze mirrors in place (or use /exclude to remove them):
|
||||
kubemirror.raczylo.com/paused: "true"
|
||||
data:
|
||||
password: c2VjcmV0
|
||||
```
|
||||
|
||||
### Mirror Custom Resources (CRDs)
|
||||
|
||||
KubeMirror works with any custom resource:
|
||||
@@ -555,12 +583,17 @@ Complete configuration reference:
|
||||
| **Resource Discovery** | | | |
|
||||
| `controller.resourceTypes` | Explicit resource type list (empty = auto-discover all) | `[]` | `["Secret.v1", "ConfigMap.v1", "Ingress.v1.networking.k8s.io"]` |
|
||||
| `controller.discoveryInterval` | Rediscovery interval for auto-discovery mode | `5m` | `10m`, `1h` |
|
||||
| `controller.lazyWatcherInit` | Only watch resource types in use; lowers memory | `false` | `true`, `false` |
|
||||
| `controller.watcherScanInterval` | Scan interval for new types in lazy mode | `5m` | `10m` |
|
||||
| **Performance & Limits** | | | |
|
||||
| `controller.leaderElect` | Enable leader election for HA | `true` | `true`, `false` |
|
||||
| `controller.leaderElectionID` | Leader election lease name | `kubemirror-controller-leader` | |
|
||||
| `controller.maxTargets` | Maximum mirrors per source resource | `100` | `50`, `200`, `500` |
|
||||
| `controller.workerThreads` | Concurrent reconciliation workers | `5` | `10`, `20` |
|
||||
| `controller.rateLimitQPS` | API rate limit (queries per second) | `50.0` | `100.0`, `200.0` |
|
||||
| `controller.rateLimitBurst` | API burst allowance | `100` | `200`, `500` |
|
||||
| `controller.resyncPeriod` | Full cache resync period | `10m` | `30m` |
|
||||
| `controller.verifySourceFreshness` | Verify cache against a direct API read before mirroring | `false` | `true`, `false` |
|
||||
| **Namespace Filtering** | | | |
|
||||
| `controller.excludedNamespaces` | Comma-separated namespace exclusion list | `""` | `kube-system,kube-public,kube-node-lease` |
|
||||
| `controller.includedNamespaces` | Comma-separated namespace inclusion list | `""` | `app-*,prod-*` |
|
||||
@@ -579,15 +612,19 @@ When running the binary directly:
|
||||
|
||||
**Resource Discovery:**
|
||||
- `--resource-types string` - Comma-separated list (e.g., `Secret.v1,ConfigMap.v1,Ingress.v1.networking.k8s.io`)
|
||||
- `--discovery-interval duration` - Rediscovery interval (default: 5m)
|
||||
- `--discovery-interval duration` - Rediscovery interval for auto-discovery mode (default: 5m)
|
||||
- `--lazy-watcher-init` - Only create watchers for resource types actually in use; lowers memory (default: false)
|
||||
- `--watcher-scan-interval duration` - Scan interval for new types in lazy mode (default: 5m)
|
||||
|
||||
**Performance & Limits:**
|
||||
- `--leader-elect` - Enable leader election (default: true)
|
||||
- `--leader-elect` - Enable leader election (default: false)
|
||||
- `--leader-election-id string` - Leader election lease name (default: kubemirror-controller-leader)
|
||||
- `--max-targets int` - Max mirrors per source (default: 100)
|
||||
- `--worker-threads int` - Concurrent workers (default: 5)
|
||||
- `--rate-limit-qps float32` - API rate limit (default: 50.0)
|
||||
- `--rate-limit-burst int` - API burst limit (default: 100)
|
||||
- `--verify-source-freshness` - Verify cache freshness before mirroring (default: false)
|
||||
- `--resync-period duration` - Full cache resync period (default: 10m)
|
||||
- `--verify-source-freshness` - Verify cache against a direct API read before mirroring (default: true)
|
||||
|
||||
**Namespace Filtering:**
|
||||
- `--excluded-namespaces string` - Comma-separated exclusion list
|
||||
@@ -970,6 +1007,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.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# kubemirror Helm chart
|
||||
|
||||
Deploys the [kubemirror](https://github.com/lukaszraczylo/kubemirror) controller,
|
||||
which mirrors Kubernetes resources (Secrets, ConfigMaps, and other types) across
|
||||
namespaces.
|
||||
|
||||
- Chart version: `0.1.0`
|
||||
- App version: `0.1.0`
|
||||
- Image: `ghcr.io/lukaszraczylo/kubemirror`
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
helm install kubemirror ./charts/kubemirror -n kubemirror --create-namespace
|
||||
```
|
||||
|
||||
With a values override:
|
||||
|
||||
```bash
|
||||
helm install kubemirror ./charts/kubemirror -n kubemirror --create-namespace \
|
||||
-f my-values.yaml
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
helm uninstall kubemirror -n kubemirror
|
||||
```
|
||||
|
||||
## What it creates
|
||||
|
||||
A single-replica `Deployment`, a `ServiceAccount`, a cluster-scoped `ClusterRole`
|
||||
+ `ClusterRoleBinding` (the controller watches resources across all namespaces),
|
||||
and a `Service` exposing the metrics (`8080`) and health (`8081`) ports. The
|
||||
container runs as non-root (uid `65532`) with a read-only root filesystem and all
|
||||
Linux capabilities dropped.
|
||||
|
||||
## Values
|
||||
|
||||
Defaults are taken from [`values.yaml`](values.yaml).
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `replicaCount` | `1` | Number of controller replicas. |
|
||||
| `image.repository` | `ghcr.io/lukaszraczylo/kubemirror` | Controller image. |
|
||||
| `image.tag` | `"0.1.0"` | Image tag (falls back to chart `appVersion` if empty). |
|
||||
| `image.pullPolicy` | `IfNotPresent` | Image pull policy. |
|
||||
| `serviceAccount.create` | `true` | Create a ServiceAccount. |
|
||||
| `serviceAccount.name` | `""` | Override the generated ServiceAccount name. |
|
||||
| `controller.metricsBindAddress` | `":8080"` | `--metrics-bind-address`. |
|
||||
| `controller.healthProbeBindAddress` | `":8081"` | `--health-probe-bind-address`. |
|
||||
| `controller.leaderElect` | `true` | Enable leader election (`--leader-elect`). |
|
||||
| `controller.leaderElectionID` | `"kubemirror-controller-leader"` | Lease name (`--leader-election-id`). |
|
||||
| `controller.resourceTypes` | `[]` | Resource types to mirror (`--resource-types`). Empty enables auto-discovery. |
|
||||
| `controller.discoveryInterval` | `"5m"` | Auto-discovery interval (`--discovery-interval`). |
|
||||
| `controller.resyncPeriod` | `"10m"` | Cache resync period (`--resync-period`). |
|
||||
| `controller.maxTargets` | `100` | Max target namespaces per resource (`--max-targets`). |
|
||||
| `controller.workerThreads` | `5` | Concurrent reconcile workers (`--worker-threads`). |
|
||||
| `controller.rateLimitQPS` | `50.0` | API QPS limit (`--rate-limit-qps`). |
|
||||
| `controller.rateLimitBurst` | `100` | API burst limit (`--rate-limit-burst`). |
|
||||
| `controller.verifySourceFreshness` | `false` | Verify cache against a direct API read (`--verify-source-freshness`). |
|
||||
| `controller.lazyWatcherInit` | `false` | Only watch resource types actually in use (`--lazy-watcher-init`). Lowers memory; recommended for production. |
|
||||
| `controller.watcherScanInterval` | `"5m"` | Scan interval for new types in lazy mode (`--watcher-scan-interval`). |
|
||||
| `controller.excludedNamespaces` | `""` | Comma-separated namespaces never mirrored to (`--excluded-namespaces`). |
|
||||
| `controller.includedNamespaces` | `""` | Comma-separated namespace patterns to include (`--included-namespaces`). |
|
||||
| `service.type` | `ClusterIP` | Service type. |
|
||||
| `service.metricsPort` | `8080` | Metrics port. |
|
||||
| `service.healthPort` | `8081` | Health-probe port. |
|
||||
| `resources.requests` | `100m` CPU / `128Mi` | Container requests. |
|
||||
| `resources.limits` | `500m` CPU / `512Mi` | Container limits. |
|
||||
| `nodeSelector` / `tolerations` / `affinity` | `{}` / `[]` / `{}` | Standard scheduling controls. |
|
||||
| `priorityClassName` | `""` | Pod priority class. |
|
||||
|
||||
> **Memory tip:** setting `controller.resourceTypes` to the exact types you mirror
|
||||
> (e.g. `["Secret.v1", "ConfigMap.v1"]`), or enabling `controller.lazyWatcherInit`,
|
||||
> avoids creating watchers for unused resource types and cuts memory use
|
||||
> substantially versus full auto-discovery.
|
||||
|
||||
## Usage
|
||||
|
||||
After install, mark a source resource for mirroring and opt a target namespace
|
||||
in. See the [project README](../../README.md) and [examples](../../examples/) for
|
||||
the full annotation/label reference and worked scenarios.
|
||||
+102
-16
@@ -3,10 +3,13 @@ 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"
|
||||
@@ -18,13 +21,20 @@ import (
|
||||
"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")
|
||||
@@ -34,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
|
||||
@@ -80,10 +110,12 @@ func main() {
|
||||
"Burst limit for API server requests.")
|
||||
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. "+
|
||||
@@ -91,8 +123,10 @@ func main() {
|
||||
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()
|
||||
@@ -100,7 +134,7 @@ func main() {
|
||||
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
|
||||
|
||||
setupLog.Info("starting kubemirror controller",
|
||||
"version", "dev",
|
||||
"version", Version,
|
||||
"maxTargets", maxTargets,
|
||||
"workers", workerThreads,
|
||||
)
|
||||
@@ -143,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 != "" {
|
||||
@@ -165,7 +207,7 @@ func main() {
|
||||
// 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) {
|
||||
transformFunc := func(obj any) (any, error) {
|
||||
// Type assert to unstructured
|
||||
u, ok := obj.(*unstructured.Unstructured)
|
||||
if !ok {
|
||||
@@ -212,13 +254,29 @@ func main() {
|
||||
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)
|
||||
@@ -227,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)
|
||||
}
|
||||
@@ -250,8 +309,21 @@ 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(),
|
||||
)
|
||||
|
||||
// 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",
|
||||
)
|
||||
}
|
||||
|
||||
// Choose between lazy watcher initialization (scan for active resources) or eager (register all)
|
||||
if lazyWatcherInit {
|
||||
@@ -270,6 +342,7 @@ func main() {
|
||||
NamespaceLister: namespaceLister,
|
||||
GVK: gvk,
|
||||
APIReader: mgr.GetAPIReader(),
|
||||
CircuitBreaker: cb,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +357,7 @@ func main() {
|
||||
// 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,
|
||||
@@ -295,7 +369,8 @@ func main() {
|
||||
})
|
||||
|
||||
// Start dynamic controller manager
|
||||
if err := dynamicMgr.Start(signalCtx); err != nil {
|
||||
err = dynamicMgr.Start(signalCtx)
|
||||
if err != nil {
|
||||
setupLog.Error(err, "unable to start dynamic controller manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -325,6 +400,7 @@ func main() {
|
||||
NamespaceLister: namespaceLister,
|
||||
GVK: gvk,
|
||||
APIReader: mgr.GetAPIReader(), // Direct API reader (bypasses cache)
|
||||
CircuitBreaker: cb,
|
||||
}
|
||||
|
||||
if err = sourceReconciler.SetupWithManagerForResourceType(mgr, gvk); err != nil {
|
||||
@@ -361,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 {
|
||||
@@ -371,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)
|
||||
}
|
||||
|
||||
+42
-26
@@ -113,13 +113,15 @@ kubectl get middleware headers -n namespace-3
|
||||
Verify that mirrored resources have the correct ownership labels:
|
||||
|
||||
```bash
|
||||
# Check labels on a mirrored secret
|
||||
kubectl get secret shared-credentials -n namespace-3 -o yaml | grep -A 5 labels
|
||||
# Inspect a mirrored secret's metadata
|
||||
kubectl get secret shared-credentials -n namespace-3 -o yaml
|
||||
|
||||
# Should include:
|
||||
# kubemirror.raczylo.com/mirrored: "true"
|
||||
# kubemirror.raczylo.com/source-namespace: namespace-1
|
||||
# kubemirror.raczylo.com/source-name: shared-credentials
|
||||
# Labels should include:
|
||||
# kubemirror.raczylo.com/managed-by: kubemirror
|
||||
# kubemirror.raczylo.com/mirror: "true"
|
||||
# Annotations should include:
|
||||
# kubemirror.raczylo.com/source-namespace: namespace-1
|
||||
# kubemirror.raczylo.com/source-name: shared-credentials
|
||||
```
|
||||
|
||||
## Testing Update Propagation
|
||||
@@ -213,26 +215,12 @@ kubectl get pods -n kubemirror-system
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Mirror to All Except Specific Namespaces
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: almost-all
|
||||
namespace: namespace-1
|
||||
annotations:
|
||||
kubemirror.raczylo.com/sync: "true"
|
||||
kubemirror.raczylo.com/target-namespaces: "all"
|
||||
kubemirror.raczylo.com/excluded-namespaces: "namespace-3"
|
||||
labels:
|
||||
kubemirror.raczylo.com/enabled: "true"
|
||||
data:
|
||||
key: dmFsdWU= # "value" in base64
|
||||
```
|
||||
|
||||
### Pattern-Based Mirroring
|
||||
|
||||
`target-namespaces` accepts glob patterns (`*` and `?`, matched with Go's
|
||||
`filepath.Match`), so you can target a family of namespaces without listing each
|
||||
one. Comma-separate multiple patterns, e.g. `"app-*,prod-*"`.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
@@ -241,14 +229,42 @@ metadata:
|
||||
namespace: namespace-1
|
||||
annotations:
|
||||
kubemirror.raczylo.com/sync: "true"
|
||||
kubemirror.raczylo.com/target-namespaces: "all"
|
||||
kubemirror.raczylo.com/namespace-pattern: "app-.*"
|
||||
# Mirror to every namespace whose name starts with "app-"
|
||||
kubemirror.raczylo.com/target-namespaces: "app-*"
|
||||
labels:
|
||||
kubemirror.raczylo.com/enabled: "true"
|
||||
data:
|
||||
config: "value"
|
||||
```
|
||||
|
||||
> **Note:** Target patterns are include-only; there is no "all except namespace
|
||||
> X" target syntax. For the two real exclusion mechanisms:
|
||||
>
|
||||
> - To stop a single source resource from being mirrored (and tear down any
|
||||
> mirrors it already created), set `kubemirror.raczylo.com/exclude: "true"` on
|
||||
> that resource.
|
||||
> - To stop specific namespaces from ever receiving mirrors cluster-wide, use the
|
||||
> controller's `--excluded-namespaces` flag.
|
||||
|
||||
### Excluding a Specific Resource
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: do-not-mirror
|
||||
namespace: namespace-1
|
||||
annotations:
|
||||
kubemirror.raczylo.com/sync: "true"
|
||||
kubemirror.raczylo.com/target-namespaces: "all"
|
||||
# Opt this resource out: it will not be mirrored even though sync is set.
|
||||
kubemirror.raczylo.com/exclude: "true"
|
||||
labels:
|
||||
kubemirror.raczylo.com/enabled: "true"
|
||||
data:
|
||||
key: dmFsdWU=
|
||||
```
|
||||
|
||||
## ExternalSecrets Integration
|
||||
|
||||
KubeMirror integrates seamlessly with the [ExternalSecrets Operator](https://external-secrets.io/) to distribute secrets from external stores (1Password, Vault, AWS Secrets Manager, etc.) across multiple namespaces.
|
||||
|
||||
@@ -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/go-logr/logr v1.4.4
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3
|
||||
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.2
|
||||
k8s.io/apimachinery v0.36.2
|
||||
k8s.io/client-go v0.36.2
|
||||
sigs.k8s.io/controller-runtime v0.24.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -18,60 +19,58 @@ 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 v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/swag v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/conv v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/loading v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/pools v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.27.3 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.27.3 // 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
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
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_golang v1.24.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/common v0.70.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.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.49.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/term v0.39.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.40.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-20260108192941-914a6e750570 // indirect
|
||||
k8s.io/apiextensions-apiserver v0.36.2 // indirect
|
||||
k8s.io/klog/v2 v2.140.0 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // 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.2 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
@@ -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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
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.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/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 v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
||||
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
|
||||
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
|
||||
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
|
||||
github.com/go-openapi/swag v0.27.3 h1:i6oVKkGZeFgETHMiBHGtj9gIQ1aLtWDdJnT/SRZeets=
|
||||
github.com/go-openapi/swag v0.27.3/go.mod h1:qEXs3GcyyQTDCFQ4ykqnLPDh8qT+zBcjbVWdxCAW0Us=
|
||||
github.com/go-openapi/swag/cmdutils v0.27.3 h1:sjuL0TvW81i9R9GRMO/fy+c3mOW+7zxRYwy/7fobZt4=
|
||||
github.com/go-openapi/swag/cmdutils v0.27.3/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
|
||||
github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU=
|
||||
github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ=
|
||||
github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE=
|
||||
github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
|
||||
github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc=
|
||||
github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
|
||||
github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0=
|
||||
github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE=
|
||||
github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg=
|
||||
github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
|
||||
github.com/go-openapi/swag/netutils v0.27.3 h1:IoBvfCoprsE6E87kAIm9basnISqDDqB79mJ8MN+f5PU=
|
||||
github.com/go-openapi/swag/netutils v0.27.3/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
|
||||
github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk=
|
||||
github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
|
||||
github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI=
|
||||
github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
|
||||
github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4=
|
||||
github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs=
|
||||
github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
|
||||
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
|
||||
github.com/go-openapi/testify/v2 v2.6.0/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=
|
||||
@@ -73,14 +71,16 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3/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,23 +89,23 @@ 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=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
|
||||
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
|
||||
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.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
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.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
|
||||
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
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-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
|
||||
k8s.io/utils v0.0.0-20260108192941-914a6e750570/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.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
|
||||
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
|
||||
k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4=
|
||||
k8s.io/apiextensions-apiserver v0.36.2/go.mod h1:cL1tBWe8XSaP1H30iWKGo7hf6iAUUUJPEU70dskmAnA=
|
||||
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
|
||||
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
|
||||
k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
|
||||
k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
|
||||
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-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
|
||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
|
||||
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.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/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=
|
||||
|
||||
@@ -159,9 +159,14 @@ histogram_quantile(0.99,
|
||||
)
|
||||
|
||||
# Current workqueue depth
|
||||
workqueue_depth{name=~"secret|configmap"}
|
||||
workqueue_depth{name=~"Secret.*|ConfigMap.*"}
|
||||
```
|
||||
|
||||
> **Controller label values:** kubemirror names each controller `<Kind>.<version>.<group>`
|
||||
> (e.g. `Secret.v1.`, `ConfigMap.v1.`) plus a `-mirror` variant for orphan
|
||||
> detection, so PromQL filters use prefixes like `controller=~"Secret.*"` rather
|
||||
> than `controller="secret"`. Adjust the kind for other mirrored resource types.
|
||||
|
||||
### Using kubectl
|
||||
|
||||
```bash
|
||||
|
||||
@@ -134,12 +134,12 @@
|
||||
"pluginVersion": "8.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(controller_runtime_reconcile_total{controller=\"secret\"}[5m])) by (result)",
|
||||
"expr": "sum(rate(controller_runtime_reconcile_total{controller=~\"Secret.*\"}[5m])) by (result)",
|
||||
"legendFormat": "Secret - {{result}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "sum(rate(controller_runtime_reconcile_total{controller=\"configmap\"}[5m])) by (result)",
|
||||
"expr": "sum(rate(controller_runtime_reconcile_total{controller=~\"ConfigMap.*\"}[5m])) by (result)",
|
||||
"legendFormat": "ConfigMap - {{result}}",
|
||||
"refId": "B"
|
||||
}
|
||||
@@ -198,7 +198,7 @@
|
||||
"pluginVersion": "8.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(workqueue_depth{name=~\"secret|configmap\"})",
|
||||
"expr": "sum(workqueue_depth{name=~\"Secret.*|ConfigMap.*\"})",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -266,22 +266,22 @@
|
||||
"pluginVersion": "8.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.99, sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller=\"secret\"}[5m])) by (le))",
|
||||
"expr": "histogram_quantile(0.99, sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller=~\"Secret.*\"}[5m])) by (le))",
|
||||
"legendFormat": "Secret p99",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller=\"secret\"}[5m])) by (le))",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller=~\"Secret.*\"}[5m])) by (le))",
|
||||
"legendFormat": "Secret p95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.99, sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller=\"configmap\"}[5m])) by (le))",
|
||||
"expr": "histogram_quantile(0.99, sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller=~\"ConfigMap.*\"}[5m])) by (le))",
|
||||
"legendFormat": "ConfigMap p99",
|
||||
"refId": "C"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller=\"configmap\"}[5m])) by (le))",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(controller_runtime_reconcile_time_seconds_bucket{controller=~\"ConfigMap.*\"}[5m])) by (le))",
|
||||
"legendFormat": "ConfigMap p95",
|
||||
"refId": "D"
|
||||
}
|
||||
@@ -350,12 +350,12 @@
|
||||
"pluginVersion": "8.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "workqueue_depth{name=\"secret\"}",
|
||||
"expr": "workqueue_depth{name=~\"Secret.*\"}",
|
||||
"legendFormat": "Secret",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "workqueue_depth{name=\"configmap\"}",
|
||||
"expr": "workqueue_depth{name=~\"ConfigMap.*\"}",
|
||||
"legendFormat": "ConfigMap",
|
||||
"refId": "B"
|
||||
}
|
||||
@@ -571,12 +571,12 @@
|
||||
"pluginVersion": "8.0.0",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(controller_runtime_reconcile_errors_total{controller=\"secret\"}[5m])) / sum(rate(controller_runtime_reconcile_total{controller=\"secret\"}[5m]))",
|
||||
"expr": "sum(rate(controller_runtime_reconcile_errors_total{controller=~\"Secret.*\"}[5m])) / sum(rate(controller_runtime_reconcile_total{controller=~\"Secret.*\"}[5m]))",
|
||||
"legendFormat": "Secret Error Rate",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "sum(rate(controller_runtime_reconcile_errors_total{controller=\"configmap\"}[5m])) / sum(rate(controller_runtime_reconcile_total{controller=\"configmap\"}[5m]))",
|
||||
"expr": "sum(rate(controller_runtime_reconcile_errors_total{controller=~\"ConfigMap.*\"}[5m])) / sum(rate(controller_runtime_reconcile_total{controller=~\"ConfigMap.*\"}[5m]))",
|
||||
"legendFormat": "ConfigMap Error Rate",
|
||||
"refId": "B"
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ spec:
|
||||
|
||||
- alert: KubeMirrorHighReconcileErrors
|
||||
expr: |
|
||||
rate(controller_runtime_reconcile_errors_total{controller="secret"}[5m]) > 0.1
|
||||
rate(controller_runtime_reconcile_errors_total{controller=~"Secret.*"}[5m]) > 0.1
|
||||
or
|
||||
rate(controller_runtime_reconcile_errors_total{controller="configmap"}[5m]) > 0.1
|
||||
rate(controller_runtime_reconcile_errors_total{controller=~"ConfigMap.*"}[5m]) > 0.1
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
@@ -39,7 +39,7 @@ spec:
|
||||
- alert: KubeMirrorReconcileLatencyHigh
|
||||
expr: |
|
||||
histogram_quantile(0.99,
|
||||
rate(controller_runtime_reconcile_time_seconds_bucket{controller=~"secret|configmap"}[5m])
|
||||
rate(controller_runtime_reconcile_time_seconds_bucket{controller=~"Secret.*|ConfigMap.*"}[5m])
|
||||
) > 5
|
||||
for: 10m
|
||||
labels:
|
||||
@@ -51,7 +51,7 @@ spec:
|
||||
|
||||
- alert: KubeMirrorWorkqueueDepthHigh
|
||||
expr: |
|
||||
workqueue_depth{name=~"secret|configmap"} > 100
|
||||
workqueue_depth{name=~"Secret.*|ConfigMap.*"} > 100
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
@@ -74,9 +74,9 @@ spec:
|
||||
# Resource mirror alerts
|
||||
- alert: KubeMirrorHighFailureRate
|
||||
expr: |
|
||||
sum(rate(controller_runtime_reconcile_errors_total{controller=~"secret|configmap"}[5m]))
|
||||
sum(rate(controller_runtime_reconcile_errors_total{controller=~"Secret.*|ConfigMap.*"}[5m]))
|
||||
/
|
||||
sum(rate(controller_runtime_reconcile_total{controller=~"secret|configmap"}[5m]))
|
||||
sum(rate(controller_runtime_reconcile_total{controller=~"Secret.*|ConfigMap.*"}[5m]))
|
||||
> 0.05
|
||||
for: 10m
|
||||
labels:
|
||||
@@ -118,29 +118,29 @@ spec:
|
||||
- record: kubemirror:reconcile_duration_seconds:p99
|
||||
expr: |
|
||||
histogram_quantile(0.99,
|
||||
rate(controller_runtime_reconcile_time_seconds_bucket{controller=~"secret|configmap"}[5m])
|
||||
rate(controller_runtime_reconcile_time_seconds_bucket{controller=~"Secret.*|ConfigMap.*"}[5m])
|
||||
)
|
||||
|
||||
- record: kubemirror:reconcile_duration_seconds:p95
|
||||
expr: |
|
||||
histogram_quantile(0.95,
|
||||
rate(controller_runtime_reconcile_time_seconds_bucket{controller=~"secret|configmap"}[5m])
|
||||
rate(controller_runtime_reconcile_time_seconds_bucket{controller=~"Secret.*|ConfigMap.*"}[5m])
|
||||
)
|
||||
|
||||
- record: kubemirror:reconcile_duration_seconds:p50
|
||||
expr: |
|
||||
histogram_quantile(0.50,
|
||||
rate(controller_runtime_reconcile_time_seconds_bucket{controller=~"secret|configmap"}[5m])
|
||||
rate(controller_runtime_reconcile_time_seconds_bucket{controller=~"Secret.*|ConfigMap.*"}[5m])
|
||||
)
|
||||
|
||||
- record: kubemirror:reconcile_rate:5m
|
||||
expr: |
|
||||
sum(rate(controller_runtime_reconcile_total{controller=~"secret|configmap"}[5m])) by (controller, result)
|
||||
sum(rate(controller_runtime_reconcile_total{controller=~"Secret.*|ConfigMap.*"}[5m])) by (controller, result)
|
||||
|
||||
- record: kubemirror:reconcile_errors:rate5m
|
||||
expr: |
|
||||
sum(rate(controller_runtime_reconcile_errors_total{controller=~"secret|configmap"}[5m])) by (controller)
|
||||
sum(rate(controller_runtime_reconcile_errors_total{controller=~"Secret.*|ConfigMap.*"}[5m])) by (controller)
|
||||
|
||||
- record: kubemirror:workqueue_depth:max
|
||||
expr: |
|
||||
max(workqueue_depth{name=~"secret|configmap"}) by (name)
|
||||
max(workqueue_depth{name=~"Secret.*|ConfigMap.*"}) by (name)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
+74
-48
@@ -1,52 +1,91 @@
|
||||
// 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 opts a resource out of mirroring when "true", overriding
|
||||
// the enabled label and sync annotation. Any mirrors it previously created are
|
||||
// removed. Use AnnotationPaused instead to freeze mirrors without deleting them.
|
||||
// Annotation because: used for configuration, not filtering.
|
||||
AnnotationExclude = Domain + "/exclude"
|
||||
|
||||
// AnnotationMaxTargets overrides the default maximum target limit per resource.
|
||||
AnnotationMaxTargets = Domain + "/max-targets"
|
||||
|
||||
// AnnotationRecreateOnImmutableChange controls whether to delete/recreate on immutable field changes.
|
||||
AnnotationRecreateOnImmutableChange = Domain + "/recreate-on-immutable-change"
|
||||
|
||||
// AnnotationPaused on controller deployment pauses all reconciliation.
|
||||
// AnnotationPaused freezes a source's mirrors when "true": existing mirrors
|
||||
// are left untouched (no updates, no orphan cleanup) until the annotation is
|
||||
// removed. Unlike AnnotationExclude, pausing does not delete existing mirrors.
|
||||
// Annotation because: operational control, not used for filtering.
|
||||
AnnotationPaused = Domain + "/paused"
|
||||
|
||||
// Source Resource Annotations (tracking)
|
||||
|
||||
// AnnotationContentHash stores the SHA256 hash of the source resource content.
|
||||
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 +94,37 @@ 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"
|
||||
// --- Transformation Annotations ---
|
||||
// These configure resource transformation during mirroring.
|
||||
|
||||
// AnnotationWebhookError stores webhook rejection error message.
|
||||
AnnotationWebhookError = Domain + "/webhook-error"
|
||||
|
||||
// AnnotationTargetNamespaceUID tracks the UID of the target namespace.
|
||||
AnnotationTargetNamespaceUID = Domain + "/target-namespace-uid"
|
||||
|
||||
// AnnotationDeletionAttempts tracks number of failed deletion attempts.
|
||||
AnnotationDeletionAttempts = Domain + "/deletion-attempts"
|
||||
|
||||
// Transformation Annotations
|
||||
|
||||
// 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
|
||||
@@ -128,13 +163,4 @@ var (
|
||||
"bootstrap.kubernetes.io/token",
|
||||
"helm.sh/release.v1",
|
||||
}
|
||||
|
||||
// Default Denied Resource Types
|
||||
DefaultDeniedResourceTypes = []string{
|
||||
"events",
|
||||
"pods",
|
||||
"replicasets",
|
||||
"endpoints",
|
||||
"endpointslices",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -18,6 +18,32 @@ import (
|
||||
"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.
|
||||
//
|
||||
@@ -30,22 +56,25 @@ import (
|
||||
// 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
|
||||
mgr ctrl.Manager
|
||||
config *config.Config
|
||||
filter *filter.NamespaceFilter
|
||||
namespaceLister NamespaceLister
|
||||
scanInterval time.Duration
|
||||
|
||||
// Tracking state
|
||||
mu sync.RWMutex
|
||||
registeredControllers map[string]bool // GVK string -> registered
|
||||
activeResourceTypes map[string]schema.GroupVersionKind
|
||||
availableResourceTypes []config.ResourceType
|
||||
|
||||
// Reconciler factories
|
||||
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
|
||||
@@ -57,14 +86,15 @@ 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
|
||||
NamespaceLister NamespaceLister
|
||||
AvailableResources []config.ResourceType
|
||||
ScanInterval time.Duration // How often to scan for new resources (default: 5m)
|
||||
SourceReconcilerFactory SourceReconcilerFactory
|
||||
MirrorReconcilerFactory MirrorReconcilerFactory
|
||||
AvailableResources []config.ResourceType
|
||||
ScanInterval time.Duration
|
||||
}
|
||||
|
||||
// NewDynamicControllerManager creates a new dynamic controller manager
|
||||
@@ -73,40 +103,61 @@ func NewDynamicControllerManager(cfg DynamicManagerConfig) *DynamicControllerMan
|
||||
cfg.ScanInterval = 5 * time.Minute
|
||||
}
|
||||
|
||||
return &DynamicControllerManager{
|
||||
d := &DynamicControllerManager{
|
||||
client: cfg.Client,
|
||||
apiReader: cfg.APIReader,
|
||||
mgr: cfg.Manager,
|
||||
config: cfg.Config,
|
||||
filter: cfg.Filter,
|
||||
namespaceLister: cfg.NamespaceLister,
|
||||
scanInterval: cfg.ScanInterval,
|
||||
registeredControllers: make(map[string]bool),
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// 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")
|
||||
@@ -126,64 +177,160 @@ func (d *DynamicControllerManager) run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// scanAndRegister scans the cluster for resources needing watchers and registers controllers
|
||||
// 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")
|
||||
|
||||
// Find resource types that have active source resources
|
||||
activeTypes, err := d.findActiveResourceTypes(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find active resource types: %w", err)
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
// Track changes
|
||||
var newlyRegistered, alreadyRegistered int
|
||||
|
||||
// Register controllers for active resource types
|
||||
for gvkStr, gvk := range activeTypes {
|
||||
if d.registeredControllers[gvkStr] {
|
||||
alreadyRegistered++
|
||||
continue
|
||||
}
|
||||
|
||||
// Register new controller
|
||||
if err := d.registerController(ctx, gvk); err != nil {
|
||||
logger.Error(err, "failed to register controller",
|
||||
"gvk", gvkStr,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
d.registeredControllers[gvkStr] = true
|
||||
d.activeResourceTypes[gvkStr] = gvk
|
||||
newlyRegistered++
|
||||
|
||||
logger.Info("registered controller for active resource type",
|
||||
"group", gvk.Group,
|
||||
"version", gvk.Version,
|
||||
"kind", gvk.Kind,
|
||||
)
|
||||
// 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,
|
||||
"totalRegistered", len(d.registeredControllers),
|
||||
"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()
|
||||
@@ -204,7 +351,7 @@ func (d *DynamicControllerManager) findActiveResourceTypes(ctx context.Context)
|
||||
},
|
||||
}
|
||||
|
||||
if err := d.client.List(ctx, list, opts...); err != nil {
|
||||
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,
|
||||
@@ -226,8 +373,10 @@ func (d *DynamicControllerManager) findActiveResourceTypes(ctx context.Context)
|
||||
return activeTypes, nil
|
||||
}
|
||||
|
||||
// registerController registers source and mirror controllers for a GVK
|
||||
func (d *DynamicControllerManager) registerController(ctx context.Context, gvk schema.GroupVersionKind) error {
|
||||
// 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
|
||||
@@ -235,9 +384,37 @@ func (d *DynamicControllerManager) registerController(ctx context.Context, gvk s
|
||||
|
||||
// Register source controller
|
||||
if err := sourceReconciler.SetupWithManagerForResourceType(d.mgr, gvk); err != nil {
|
||||
return fmt.Errorf("failed to register source controller: %w", err)
|
||||
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)
|
||||
|
||||
@@ -246,11 +423,53 @@ func (d *DynamicControllerManager) registerController(ctx context.Context, gvk s
|
||||
return fmt.Errorf("failed to register mirror controller: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("registered controllers",
|
||||
"group", gvk.Group,
|
||||
"version", gvk.Version,
|
||||
"kind", gvk.Kind,
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -21,11 +21,17 @@ import (
|
||||
// These are intentionally not exported methods on DynamicControllerManager
|
||||
// to avoid exposing them in production code
|
||||
|
||||
// getRegisteredCount returns the number of currently registered controllers (test helper)
|
||||
// getRegisteredCount returns the number of fully registered controllers (test helper)
|
||||
func getRegisteredCount(d *DynamicControllerManager) int {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return len(d.registeredControllers)
|
||||
count := 0
|
||||
for _, state := range d.registrationState {
|
||||
if state == StateFullyRegistered {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// getActiveResourceTypes returns the currently active resource types (test helper)
|
||||
@@ -49,8 +55,8 @@ func TestDynamicControllerManager_FindActiveResourceTypes(t *testing.T) {
|
||||
name string
|
||||
availableResources []config.ResourceType
|
||||
existingResources []*unstructured.Unstructured
|
||||
expectedActiveCount int
|
||||
expectedActiveTypes []string
|
||||
expectedActiveCount int
|
||||
}{
|
||||
{
|
||||
name: "no resources marked for mirroring",
|
||||
@@ -242,9 +248,9 @@ func TestDynamicControllerManager_FindActiveResourceTypes(t *testing.T) {
|
||||
|
||||
func TestDynamicControllerManager_GetRegisteredCount(t *testing.T) {
|
||||
mgr := &DynamicControllerManager{
|
||||
registeredControllers: map[string]bool{
|
||||
"Secret.v1.": true,
|
||||
"ConfigMap.v1.": true,
|
||||
registrationState: map[string]RegistrationState{
|
||||
"Secret.v1.": StateFullyRegistered,
|
||||
"ConfigMap.v1.": StateFullyRegistered,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -252,6 +258,19 @@ func TestDynamicControllerManager_GetRegisteredCount(t *testing.T) {
|
||||
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"}
|
||||
@@ -319,22 +338,22 @@ func TestDynamicControllerManager_ScanInterval(t *testing.T) {
|
||||
func TestDynamicControllerManager_RegistrationTracking(t *testing.T) {
|
||||
// Test that registration tracking works correctly
|
||||
mgr := &DynamicControllerManager{
|
||||
registeredControllers: make(map[string]bool),
|
||||
activeResourceTypes: make(map[string]schema.GroupVersionKind),
|
||||
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.False(t, mgr.registeredControllers[gvkStr])
|
||||
assert.Equal(t, StateNotRegistered, mgr.registrationState[gvkStr])
|
||||
assert.Equal(t, 0, getRegisteredCount(mgr))
|
||||
|
||||
// Mark as registered
|
||||
mgr.registeredControllers[gvkStr] = true
|
||||
// Mark as fully registered
|
||||
mgr.registrationState[gvkStr] = StateFullyRegistered
|
||||
mgr.activeResourceTypes[gvkStr] = gvk
|
||||
|
||||
assert.True(t, mgr.registeredControllers[gvkStr])
|
||||
assert.Equal(t, StateFullyRegistered, mgr.registrationState[gvkStr])
|
||||
assert.Equal(t, 1, getRegisteredCount(mgr))
|
||||
|
||||
activeTypes := getActiveResourceTypes(mgr)
|
||||
@@ -342,11 +361,87 @@ func TestDynamicControllerManager_RegistrationTracking(t *testing.T) {
|
||||
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{
|
||||
registeredControllers: make(map[string]bool),
|
||||
activeResourceTypes: make(map[string]schema.GroupVersionKind),
|
||||
registrationState: make(map[string]RegistrationState),
|
||||
activeResourceTypes: make(map[string]schema.GroupVersionKind),
|
||||
}
|
||||
|
||||
// Simulate concurrent reads and writes
|
||||
@@ -356,7 +451,7 @@ func TestDynamicControllerManager_ConcurrentAccess(t *testing.T) {
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
mgr.mu.Lock()
|
||||
mgr.registeredControllers["test"] = true
|
||||
mgr.registrationState["test"] = StateFullyRegistered
|
||||
mgr.mu.Unlock()
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
@@ -381,7 +476,7 @@ func TestDynamicControllerManager_ConcurrentAccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// Should not panic and should have final state
|
||||
assert.True(t, mgr.registeredControllers["test"])
|
||||
assert.Equal(t, StateFullyRegistered, mgr.registrationState["test"])
|
||||
}
|
||||
|
||||
func TestDynamicControllerManager_UnstructuredResourceHandling(t *testing.T) {
|
||||
@@ -432,3 +527,64 @@ func TestDynamicControllerManager_UnstructuredResourceHandling(t *testing.T) {
|
||||
_, 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")
|
||||
}
|
||||
|
||||
+56
-30
@@ -3,6 +3,7 @@ package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -96,7 +97,7 @@ func filterKubeMirrorMetadata(metadata map[string]string) map[string]string {
|
||||
filtered := make(map[string]string)
|
||||
for k, v := range metadata {
|
||||
// Skip all kubemirror.raczylo.com keys
|
||||
if !strings.HasPrefix(k, "kubemirror.raczylo.com/") {
|
||||
if !strings.HasPrefix(k, constants.Domain+"/") {
|
||||
filtered[k] = v
|
||||
}
|
||||
}
|
||||
@@ -136,9 +137,7 @@ func createUnstructuredMirror(source runtime.Object, targetNamespace, sourceHash
|
||||
|
||||
// Add mirror-specific annotations
|
||||
annotations := buildMirrorAnnotations(source, sourceHash)
|
||||
for k, v := range annotations {
|
||||
existingAnnotations[k] = v
|
||||
}
|
||||
maps.Copy(existingAnnotations, annotations)
|
||||
mirror.SetAnnotations(existingAnnotations)
|
||||
|
||||
// Remove status (never mirror status)
|
||||
@@ -160,8 +159,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 +204,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 {
|
||||
@@ -253,8 +271,8 @@ func UpdateMirror(mirror, source runtime.Object) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertToStringMap converts map[string]interface{} to map[string]string.
|
||||
func convertToStringMap(data map[string]interface{}) map[string]string {
|
||||
// convertToStringMap converts map[string]any to map[string]string.
|
||||
func convertToStringMap(data map[string]any) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range data {
|
||||
if s, ok := v.(string); ok {
|
||||
@@ -264,8 +282,8 @@ func convertToStringMap(data map[string]interface{}) map[string]string {
|
||||
return result
|
||||
}
|
||||
|
||||
// convertToByteMap converts map[string]interface{} to map[string][]byte.
|
||||
func convertToByteMap(data map[string]interface{}) map[string][]byte {
|
||||
// convertToByteMap converts map[string]any to map[string][]byte.
|
||||
func convertToByteMap(data map[string]any) map[string][]byte {
|
||||
result := make(map[string][]byte)
|
||||
for k, v := range data {
|
||||
switch val := v.(type) {
|
||||
@@ -280,8 +298,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 +306,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 +324,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 +442,10 @@ func applyTransformations(source, mirror runtime.Object, targetNamespace string)
|
||||
return mirror, nil
|
||||
}
|
||||
|
||||
// Save original annotations to restore on failure
|
||||
originalAnnotations := mirrorObj.GetAnnotations()
|
||||
savedAnnotations := maps.Clone(originalAnnotations)
|
||||
|
||||
mirrorAnnotations := mirrorObj.GetAnnotations()
|
||||
if mirrorAnnotations == nil {
|
||||
mirrorAnnotations = make(map[string]string)
|
||||
@@ -437,6 +467,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
|
||||
}
|
||||
|
||||
@@ -465,18 +497,12 @@ func buildTransformContext(source, mirror runtime.Object, targetNamespace string
|
||||
|
||||
// Copy labels (if any)
|
||||
if labels := sourceObj.GetLabels(); labels != nil {
|
||||
ctx.Labels = make(map[string]string)
|
||||
for k, v := range labels {
|
||||
ctx.Labels[k] = v
|
||||
}
|
||||
ctx.Labels = maps.Clone(labels)
|
||||
}
|
||||
|
||||
// Copy annotations (if any)
|
||||
if annotations := sourceObj.GetAnnotations(); annotations != nil {
|
||||
ctx.Annotations = make(map[string]string)
|
||||
for k, v := range annotations {
|
||||
ctx.Annotations[k] = v
|
||||
}
|
||||
ctx.Annotations = maps.Clone(annotations)
|
||||
}
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package controller implements the kubemirror reconciliation logic.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
// mirrorDeletionOutcome describes the result of an ownership-verified mirror
|
||||
// deletion attempt.
|
||||
type mirrorDeletionOutcome int
|
||||
|
||||
const (
|
||||
// mirrorAbsent means no resource exists at the requested namespace/name.
|
||||
mirrorAbsent mirrorDeletionOutcome = iota
|
||||
// mirrorNotOwned means a resource exists but is not a kubemirror-managed
|
||||
// mirror of the given source, so it was left untouched.
|
||||
mirrorNotOwned
|
||||
// mirrorDeleted means an owned mirror was found and deleted.
|
||||
mirrorDeleted
|
||||
)
|
||||
|
||||
// deleteOwnedMirror deletes the mirror at gvk/ns/name only if it is managed by
|
||||
// kubemirror AND its source reference matches srcNs/srcName. This ownership
|
||||
// guard is the single safety gate that prevents deleting an unrelated resource
|
||||
// that merely shares the same name. All mirror-cleanup paths route through here
|
||||
// so the guard cannot be forgotten in one place and drift in another.
|
||||
//
|
||||
// A NotFound on delete is treated as success (the mirror is already gone).
|
||||
func deleteOwnedMirror(
|
||||
ctx context.Context,
|
||||
c client.Client,
|
||||
gvk schema.GroupVersionKind,
|
||||
ns, name, srcNs, srcName string,
|
||||
) (mirrorDeletionOutcome, error) {
|
||||
mirror := &unstructured.Unstructured{}
|
||||
mirror.SetGroupVersionKind(gvk)
|
||||
|
||||
if err := c.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, mirror); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
return mirrorAbsent, nil
|
||||
}
|
||||
return mirrorAbsent, err
|
||||
}
|
||||
|
||||
// Verify this is actually our mirror (not someone else's resource with the same name).
|
||||
if !IsManagedByUs(mirror) {
|
||||
return mirrorNotOwned, nil
|
||||
}
|
||||
|
||||
// Verify this mirror points to our source.
|
||||
refNs, refName, _, found := GetSourceReference(mirror)
|
||||
if !found || refNs != srcNs || refName != srcName {
|
||||
return mirrorNotOwned, nil
|
||||
}
|
||||
|
||||
if err := c.Delete(ctx, mirror); err != nil && !errors.IsNotFound(err) {
|
||||
return mirrorNotOwned, err
|
||||
}
|
||||
return mirrorDeleted, nil
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -132,7 +149,7 @@ func (r *MirrorReconciler) SetupWithManager(mgr ctrl.Manager, gvk schema.GroupVe
|
||||
return false
|
||||
}
|
||||
managedBy, exists := labels[constants.LabelManagedBy]
|
||||
return exists && managedBy == "kubemirror"
|
||||
return exists && managedBy == constants.ControllerName
|
||||
})
|
||||
|
||||
// Convert GVK to resource object for watching
|
||||
@@ -140,9 +157,7 @@ func (r *MirrorReconciler) SetupWithManager(mgr ctrl.Manager, gvk schema.GroupVe
|
||||
gv := schema.GroupVersion{Group: gvk.Group, Version: gvk.Version}
|
||||
obj.SetGroupVersionKind(gv.WithKind(gvk.Kind))
|
||||
|
||||
// Set custom controller name to avoid conflicts with source reconciler and multiple API versions
|
||||
// Include group and version to make it truly unique
|
||||
controllerName := gvk.Kind + "." + gvk.Version + "." + gvk.Group + "-mirror"
|
||||
controllerName := gvkControllerName(gvk, true)
|
||||
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(obj).
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestCreateMirror_Unstructured_StripsOwnerReferences(t *testing.T) {
|
||||
"externalsecrets.external-secrets.io/externalsecret-cleanup",
|
||||
},
|
||||
},
|
||||
"data": map[string]interface{}{
|
||||
"data": map[string]interface{}{ //nolint:gosec // base64 test fixture, not a real credential
|
||||
"password": "c2VjcmV0",
|
||||
},
|
||||
},
|
||||
@@ -387,7 +387,7 @@ func TestUpdateMirror_UnstructuredSecret(t *testing.T) {
|
||||
},
|
||||
},
|
||||
"type": "Opaque",
|
||||
"data": map[string]interface{}{
|
||||
"data": map[string]interface{}{ //nolint:gosec // base64 test fixture, not a real credential
|
||||
"password": "b2xkLXZhbHVl", // base64 encoded "old-value"
|
||||
},
|
||||
},
|
||||
@@ -403,7 +403,7 @@ func TestUpdateMirror_UnstructuredSecret(t *testing.T) {
|
||||
"generation": int64(10),
|
||||
},
|
||||
"type": "kubernetes.io/tls",
|
||||
"data": map[string]interface{}{
|
||||
"data": map[string]interface{}{ //nolint:gosec // base64 test fixture, not a real credential
|
||||
"password": "bmV3LXZhbHVl", // base64 encoded "new-value"
|
||||
"username": "YWRtaW4=", // base64 encoded "admin"
|
||||
},
|
||||
@@ -670,10 +670,9 @@ func TestCreateMirror_NoSyncAnnotations(t *testing.T) {
|
||||
constants.LabelEnabled: "true",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSync: "true",
|
||||
constants.AnnotationTargetNamespaces: "app1,app2",
|
||||
constants.AnnotationExclude: "false",
|
||||
constants.AnnotationRecreateOnImmutableChange: "true",
|
||||
constants.AnnotationSync: "true",
|
||||
constants.AnnotationTargetNamespaces: "app1,app2",
|
||||
constants.AnnotationExclude: "false",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{"key": []byte("value")},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,17 +4,19 @@ 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 +26,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 +82,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
|
||||
}
|
||||
|
||||
@@ -115,6 +129,13 @@ func (r *NamespaceReconciler) reconcileResourceType(ctx context.Context, rt conf
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip excluded or paused sources. Excluded resources are torn down by the
|
||||
// source reconciler; paused resources must stay frozen. In both cases the
|
||||
// namespace reconciler must not create, update, or delete their mirrors.
|
||||
if annotations[constants.AnnotationExclude] == "true" || isPaused(source) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve target namespaces for this source
|
||||
targetNamespaces, err := r.resolveTargetNamespaces(ctx, source)
|
||||
if err != nil {
|
||||
@@ -125,13 +146,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
|
||||
@@ -151,38 +166,10 @@ func (r *NamespaceReconciler) reconcileResourceType(ctx context.Context, rt conf
|
||||
"targetNamespace", namespaceName,
|
||||
"resourceType", rt.String())
|
||||
} else {
|
||||
// Namespace is no longer a target - check if mirror exists and delete it
|
||||
mirror := &unstructured.Unstructured{}
|
||||
mirror.SetGroupVersionKind(source.GroupVersionKind())
|
||||
mirror.SetNamespace(namespaceName)
|
||||
mirror.SetName(source.GetName())
|
||||
|
||||
err := r.Get(ctx, client.ObjectKey{Namespace: namespaceName, Name: source.GetName()}, mirror)
|
||||
if errors.IsNotFound(err) {
|
||||
// No mirror exists, nothing to clean up
|
||||
continue
|
||||
}
|
||||
// Namespace is no longer a target - delete the mirror if we own it.
|
||||
outcome, err := deleteOwnedMirror(ctx, r.Client, source.GroupVersionKind(),
|
||||
namespaceName, source.GetName(), source.GetNamespace(), source.GetName())
|
||||
if err != nil {
|
||||
logger.Error(err, "failed to check for mirror",
|
||||
"source", source.GetName(),
|
||||
"namespace", namespaceName)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify this is actually our mirror (not someone else's resource with the same name)
|
||||
if !IsManagedByUs(mirror) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify this mirror points to our source
|
||||
srcNs, srcName, _, found := GetSourceReference(mirror)
|
||||
if !found || srcNs != source.GetNamespace() || srcName != source.GetName() {
|
||||
continue
|
||||
}
|
||||
|
||||
// This mirror should be deleted (namespace no longer a valid target)
|
||||
if err := r.Delete(ctx, mirror); err != nil {
|
||||
logger.Error(err, "failed to delete orphaned mirror",
|
||||
"source", source.GetName(),
|
||||
"sourceNamespace", source.GetNamespace(),
|
||||
@@ -190,89 +177,53 @@ func (r *NamespaceReconciler) reconcileResourceType(ctx context.Context, rt conf
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
reconciledCount++
|
||||
logger.V(1).Info("deleted orphaned mirror due to namespace label change",
|
||||
"source", source.GetName(),
|
||||
"sourceNamespace", source.GetNamespace(),
|
||||
"targetNamespace", namespaceName,
|
||||
"resourceType", rt.String())
|
||||
if outcome == mirrorDeleted {
|
||||
reconciledCount++
|
||||
logger.V(1).Info("deleted orphaned mirror due to namespace label change",
|
||||
"source", source.GetName(),
|
||||
"sourceNamespace", source.GetNamespace(),
|
||||
"targetNamespace", namespaceName,
|
||||
"resourceType", rt.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reconciledCount, errorCount, nil
|
||||
}
|
||||
|
||||
// resolveTargetNamespaces determines which namespaces should receive mirrors for a source.
|
||||
// Uses the same logic as SourceReconciler.resolveTargetNamespaces.
|
||||
// resolveTargetNamespaces determines which namespaces should receive mirrors for
|
||||
// a source. It delegates to SourceReconciler so target-resolution logic
|
||||
// (pattern parsing/validation, namespace listing, max-targets clamping) lives in
|
||||
// exactly one place, mirroring the reconcileMirror delegation below.
|
||||
func (r *NamespaceReconciler) resolveTargetNamespaces(ctx context.Context, source *unstructured.Unstructured) ([]string, error) {
|
||||
annotations := source.GetAnnotations()
|
||||
if annotations == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
targetNsAnnotation := annotations[constants.AnnotationTargetNamespaces]
|
||||
if targetNsAnnotation == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Parse patterns
|
||||
patterns := filter.ParseTargetNamespaces(targetNsAnnotation)
|
||||
if len(patterns) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get all namespaces
|
||||
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list namespaces: %w", err)
|
||||
}
|
||||
|
||||
// Get namespaces with allow-mirrors label
|
||||
allowMirrorsNamespaces, err := r.NamespaceLister.ListAllowMirrorsNamespaces(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list allow-mirrors namespaces: %w", err)
|
||||
}
|
||||
|
||||
// Get namespaces that have explicitly opted out (allow-mirrors="false")
|
||||
optOutNamespaces, err := r.NamespaceLister.ListOptOutNamespaces(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list opt-out namespaces: %w", err)
|
||||
}
|
||||
|
||||
// Resolve target namespaces
|
||||
targetNamespaces := filter.ResolveTargetNamespaces(
|
||||
patterns,
|
||||
allNamespaces,
|
||||
allowMirrorsNamespaces,
|
||||
optOutNamespaces,
|
||||
source.GetNamespace(),
|
||||
r.Filter,
|
||||
)
|
||||
|
||||
// Enforce max targets limit
|
||||
if r.Config != nil && r.Config.MaxTargetsPerResource > 0 && len(targetNamespaces) > r.Config.MaxTargetsPerResource {
|
||||
targetNamespaces = targetNamespaces[:r.Config.MaxTargetsPerResource]
|
||||
}
|
||||
|
||||
return targetNamespaces, nil
|
||||
return r.newSourceReconciler(source.GroupVersionKind()).
|
||||
resolveTargetNamespaces(ctx, source)
|
||||
}
|
||||
|
||||
// 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 +243,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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package controller implements the kubemirror reconciliation logic.
|
||||
package controller
|
||||
|
||||
import "k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
// gvkControllerName builds the unique controller-runtime controller name for a
|
||||
// GVK. Including version and group avoids collisions across API versions, e.g.
|
||||
// "HorizontalPodAutoscaler.v1.autoscaling" or "Secret.v1." (empty group for
|
||||
// core resources). Source and mirror reconcilers for the same GVK must differ,
|
||||
// so mirror controllers get a "-mirror" suffix. This is the single source of
|
||||
// truth for the convention shared by both reconcilers.
|
||||
func gvkControllerName(gvk schema.GroupVersionKind, mirror bool) string {
|
||||
name := gvk.Kind + "." + gvk.Version + "." + gvk.Group
|
||||
if mirror {
|
||||
name += "-mirror"
|
||||
}
|
||||
return name
|
||||
}
|
||||
+227
-124
@@ -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,47 @@ 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
|
||||
}
|
||||
|
||||
// Respect a per-source pause: freeze existing mirrors in place. We skip all
|
||||
// mirror create/update and orphan cleanup, leaving any prior mirrors and the
|
||||
// finalizer untouched until the annotation is removed. Deletion and disabling
|
||||
// are handled above and intentionally take precedence over pause.
|
||||
if isPaused(sourceObj) {
|
||||
logger.V(1).Info("source is paused, leaving existing mirrors unchanged")
|
||||
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 +242,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 +258,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 +279,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 +292,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 +324,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 +378,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 +389,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 +409,67 @@ 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 {
|
||||
outcome, delErr := deleteOwnedMirror(ctx, r.Client, sourceUnstructured.GroupVersionKind(),
|
||||
ns, sourceObj.GetName(), sourceObj.GetNamespace(), sourceObj.GetName())
|
||||
if delErr != nil {
|
||||
logger.Error(delErr, "failed to delete mirror", "namespace", ns)
|
||||
deleteErrs = append(deleteErrs, fmt.Errorf("delete mirror %s/%s: %w", ns, sourceObj.GetName(), delErr))
|
||||
continue
|
||||
}
|
||||
if outcome == mirrorDeleted {
|
||||
deleteCount++
|
||||
} else if !errors.IsNotFound(err) {
|
||||
logger.Error(err, "failed to delete mirror", "namespace", ns)
|
||||
}
|
||||
}
|
||||
|
||||
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 +478,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)
|
||||
@@ -414,41 +509,16 @@ func (r *SourceReconciler) cleanupOrphanedMirrors(ctx context.Context, sourceObj
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if a mirror exists in this namespace
|
||||
mirror := &unstructured.Unstructured{}
|
||||
mirror.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
|
||||
mirror.SetNamespace(ns)
|
||||
mirror.SetName(sourceObj.GetName())
|
||||
|
||||
err := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: sourceObj.GetName()}, mirror)
|
||||
if errors.IsNotFound(err) {
|
||||
// No mirror exists, nothing to clean up
|
||||
continue
|
||||
}
|
||||
outcome, err := deleteOwnedMirror(ctx, r.Client, sourceUnstructured.GroupVersionKind(),
|
||||
ns, sourceObj.GetName(), sourceObj.GetNamespace(), sourceObj.GetName())
|
||||
if err != nil {
|
||||
logger.Error(err, "failed to check for mirror", "namespace", ns)
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify this is actually our mirror (not someone else's resource with the same name)
|
||||
if !IsManagedByUs(mirror) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify this mirror points to our source
|
||||
srcNs, srcName, _, found := GetSourceReference(mirror)
|
||||
if !found || srcNs != sourceObj.GetNamespace() || srcName != sourceObj.GetName() {
|
||||
continue
|
||||
}
|
||||
|
||||
// This is an orphaned mirror - delete it
|
||||
if err := r.Delete(ctx, mirror); err != nil {
|
||||
logger.Error(err, "failed to delete orphaned mirror", "namespace", ns)
|
||||
continue
|
||||
}
|
||||
|
||||
deletedCount++
|
||||
logger.V(1).Info("deleted orphaned mirror", "namespace", ns)
|
||||
if outcome == mirrorDeleted {
|
||||
deletedCount++
|
||||
logger.V(1).Info("deleted orphaned mirror", "namespace", ns)
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount, nil
|
||||
@@ -472,30 +542,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,21 +595,45 @@ 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))
|
||||
}
|
||||
|
||||
// isEnabledForMirroring checks if a resource has both the label and annotation for mirroring.
|
||||
// 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 reports whether a resource should be mirrored. A resource
|
||||
// must carry both the enabled label and the sync annotation, and must not be
|
||||
// explicitly excluded. An excluded resource (exclude="true") is treated as
|
||||
// disabled, so the reconcile loop tears down any mirrors it previously created.
|
||||
func isEnabledForMirroring(obj metav1.Object) bool {
|
||||
// Check label
|
||||
labels := obj.GetLabels()
|
||||
@@ -536,16 +647,21 @@ func isEnabledForMirroring(obj metav1.Object) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Explicit opt-out always wins over the enable label/annotation.
|
||||
if annotations[constants.AnnotationExclude] == "true" {
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
// isPaused reports whether a source has the pause annotation set. A paused
|
||||
// source is frozen: its existing mirrors are left exactly as they are (no
|
||||
// updates, no orphan cleanup) until the annotation is removed. Unlike
|
||||
// isEnabledForMirroring returning false, pausing does not delete mirrors.
|
||||
func isPaused(obj metav1.Object) bool {
|
||||
annotations := obj.GetAnnotations()
|
||||
return annotations != nil && annotations[constants.AnnotationPaused] == "true"
|
||||
}
|
||||
|
||||
// SetupWithManagerForResourceType sets up a controller for a specific resource type.
|
||||
@@ -558,10 +674,7 @@ func (r *SourceReconciler) SetupWithManagerForResourceType(
|
||||
obj := &unstructured.Unstructured{}
|
||||
obj.SetGroupVersionKind(gvk)
|
||||
|
||||
// Create unique controller name including version and group to avoid collisions
|
||||
// e.g., "HorizontalPodAutoscaler.v1.autoscaling" or "Secret.v1." (empty group for core resources)
|
||||
// This matches the naming convention used by mirror reconcilers
|
||||
controllerName := gvk.Kind + "." + gvk.Version + "." + gvk.Group
|
||||
controllerName := gvkControllerName(gvk, false)
|
||||
|
||||
// Create mirror object for watching
|
||||
mirrorObj := &unstructured.Unstructured{}
|
||||
@@ -611,16 +724,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))
|
||||
|
||||
@@ -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
|
||||
@@ -197,6 +205,36 @@ func TestIsEnabledForMirroring(t *testing.T) {
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "excluded overrides enabled label and sync annotation",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
constants.LabelEnabled: "true",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSync: "true",
|
||||
constants.AnnotationExclude: "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exclude set to false does not disable mirroring",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
constants.LabelEnabled: "true",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSync: "true",
|
||||
constants.AnnotationExclude: "false",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -207,6 +245,47 @@ func TestIsEnabledForMirroring(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPaused(t *testing.T) {
|
||||
tests := []struct {
|
||||
obj metav1.Object
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "paused true",
|
||||
obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{constants.AnnotationPaused: "true"},
|
||||
}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "paused false",
|
||||
obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{constants.AnnotationPaused: "false"},
|
||||
}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "annotation absent",
|
||||
obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{constants.AnnotationSync: "true"},
|
||||
}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no annotations",
|
||||
obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{}},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, isPaused(tt.obj))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceReconciler_resolveTargetNamespaces(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -280,9 +359,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 +524,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 +602,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"}
|
||||
@@ -584,7 +674,7 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
|
||||
constants.FinalizerName,
|
||||
},
|
||||
},
|
||||
"data": map[string]interface{}{
|
||||
"data": map[string]interface{}{ //nolint:gosec // base64 test fixture, not a real credential
|
||||
"password": "c2VjcmV0",
|
||||
},
|
||||
},
|
||||
@@ -613,7 +703,7 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
|
||||
constants.AnnotationSourceUID: "source-uid-123",
|
||||
},
|
||||
},
|
||||
"data": map[string]interface{}{
|
||||
"data": map[string]interface{}{ //nolint:gosec // base64 test fixture, not a real credential
|
||||
"password": "c2VjcmV0",
|
||||
},
|
||||
},
|
||||
@@ -624,14 +714,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 +750,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).
|
||||
@@ -689,6 +806,53 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
|
||||
mockLister.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestSourceReconciler_Reconcile_Paused_LeavesMirrorsUntouched(t *testing.T) {
|
||||
// A paused source must early-return: no List, Create, Delete, or Update.
|
||||
// The MockClient panics on any un-mocked call, so wiring only the source Get
|
||||
// proves Reconcile touches nothing else.
|
||||
source := &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]any{
|
||||
"name": "test-secret",
|
||||
"namespace": "default",
|
||||
"uid": "source-uid-123",
|
||||
"labels": map[string]any{
|
||||
constants.LabelEnabled: "true",
|
||||
},
|
||||
"annotations": map[string]any{
|
||||
constants.AnnotationSync: "true",
|
||||
constants.AnnotationTargetNamespaces: "all",
|
||||
constants.AnnotationPaused: "true",
|
||||
},
|
||||
"finalizers": []any{constants.FinalizerName},
|
||||
},
|
||||
"data": map[string]any{"password": "c2VjcmV0"}, //nolint:gosec // base64 test fixture, not a real credential
|
||||
},
|
||||
}
|
||||
|
||||
mockClient := new(MockClient)
|
||||
// Only the source read is expected; nothing else.
|
||||
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "test-secret"}, mock.Anything).
|
||||
Return(nil, source).Once()
|
||||
|
||||
r := &SourceReconciler{
|
||||
Client: mockClient,
|
||||
Scheme: runtime.NewScheme(),
|
||||
Config: &config.Config{},
|
||||
Filter: filter.NewNamespaceFilter(nil, nil),
|
||||
GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
|
||||
}
|
||||
|
||||
result, err := r.Reconcile(context.Background(),
|
||||
ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "test-secret"}})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ctrl.Result{}, result)
|
||||
mockClient.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T) {
|
||||
// Scenario: annotation changes from "app-*" → "prod-*"
|
||||
// Before: mirrors in app-1, app-2, app-3
|
||||
@@ -751,9 +915,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 +928,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 +997,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -316,3 +341,34 @@ var deniedKinds = map[string]bool{
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user