Compare commits

..
53 Commits
Author SHA1 Message Date
lukaszraczylo c8c1ddbcf0 fix(ci): replace broken setup-kind action
engineerd/setup-kind v0.6.2 fails with a missing dist/main/index.js file.
Use helm/kind-action instead.
2026-10-02 04:11:26 +01:00
lukaszraczyloandGitHub 007cb0a1b7 Update go.mod and go.sum (#86) 2026-10-02 03:01:09 +01:00
lukaszraczyloandGitHub 766e18a2e7 Update go.mod and go.sum (#85) 2026-09-04 04:22:30 +01:00
lukaszraczyloandGitHub cac9c373fb Update go.mod and go.sum (#84) 2026-09-01 04:25:16 +01:00
lukaszraczylo 08bb0f985a fix(docker): declare ARG TARGETARCH so buildx cross-builds work
TARGETARCH was referenced but never declared, so every platform in a
multi-arch buildx run received an amd64 binary. The arm64 image
crashed on Raspberry Pi nodes with an exec format error.
2026-08-29 13:54:07 +01:00
lukaszraczylo 14b61fa962 fix(docker): bump builder image to go 1.26
go.mod requires go >= 1.26.0 since the dependency updates, and the
golang alpine images set GOTOOLCHAIN=local, so the 1.25 builder failed
at go mod download.
2026-08-29 13:50:16 +01:00
lukaszraczylo 8988893160 perf(controller): repair mirror drift from watch events instead of resync
Mirror update events were filtered out, so an in-place edit of a
mirrored copy was invisible to the controller. The periodic resync did
not catch it either: NeedsSync only compares the source content hash
with the recorded annotation, and tampered mirror data leaves both
unchanged.

Changes:
- The mirror watch now passes real update events (resourceVersion
  changed, object is a managed mirror) and maps them to the source.
  Informer resync replays keep an unchanged resourceVersion and stay
  filtered.
- reconcileMirror detects in-place drift by rebuilding the expected
  mirror in memory and comparing content hashes. Metadata differences
  never count as drift, so the controller's own writes converge in one
  no-op pass.
- The resync-period default moves from 10m to 1h. Watches now cover
  source changes, mirror deletion, mirror drift, and namespace changes,
  so the resync is only a safety net for missed watch events. The chart
  default moves with it.
- Add predicate and tamper-repair regression tests; drift repair is
  verified to make no direct apiserver reads.
2026-08-29 13:44:57 +01:00
lukaszraczylo eaa1246fb7 perf(controller): stop per-target apiserver reads in mirror reconcile
The mirror reconcile path issued one direct apiserver GET per target
namespace on every pass. With N sources and M target namespaces each
resync produced N*M GET requests. On a 65-namespace cluster this
generated 63 GET/s and overloaded one control-plane node.

Changes:
- reconcileMirror now trusts the informer cache. Content drift is
  detected via the source hash annotation. A stale resourceVersion
  makes Update return a conflict, which requeues the source.
- The verify-source-freshness flag now defaults to false. The Helm
  chart passes the value explicitly, so the chart default of false
  matches the binary behaviour.
- Reconcile performs one namespace LIST per pass. Target resolution
  and orphan cleanup share the result.
- Add load regression tests that count direct API reads in the
  reconcile hot path.
2026-08-29 13:44:54 +01:00
lukaszraczyloandGitHub 8c0bb0c5b0 Update go.mod and go.sum (#83) 2026-08-29 04:20:19 +01:00
lukaszraczyloandGitHub 198c6c8492 Update go.mod and go.sum (#82) 2026-08-27 05:58:26 +01:00
lukaszraczyloandGitHub 3df05913ed Update go.mod and go.sum (#81) 2026-08-26 04:27:17 +01:00
lukaszraczyloandGitHub 53075d8914 Update go.mod and go.sum (#80) 2026-08-22 04:22:15 +01:00
lukaszraczyloandGitHub 23b164e731 Update go.mod and go.sum (#79) 2026-08-21 04:26:32 +01:00
lukaszraczyloandGitHub 8c09081914 Update go.mod and go.sum (#78) 2026-08-20 04:22:51 +01:00
lukaszraczyloandGitHub 5f4a5bf233 Update go.mod and go.sum (#77) 2026-08-18 04:21:51 +01:00
lukaszraczyloandGitHub 0285f9f62d Update go.mod and go.sum (#76) 2026-08-13 04:50:56 +01:00
lukaszraczyloandGitHub c23b3af4f9 Update go.mod and go.sum (#75) 2026-08-12 04:50:01 +01:00
lukaszraczyloandGitHub 58d7170742 Update go.mod and go.sum (#74) 2026-08-11 04:38:28 +01:00
lukaszraczyloandGitHub 6ad42ddf89 Update go.mod and go.sum (#73) 2026-07-28 06:54:43 +01:00
lukaszraczyloandGitHub 5b848b24ad Update go.mod and go.sum (#72) 2026-07-27 07:36:51 +01:00
lukaszraczyloandGitHub 6f880e7ade Update go.mod and go.sum (#71) 2026-07-25 06:48:17 +01:00
lukaszraczyloandGitHub ce4d3aff86 Update go.mod and go.sum (#70) 2026-07-23 07:08:06 +01:00
lukaszraczyloandGitHub 441b05def5 Update go.mod and go.sum (#69) 2026-07-22 06:57:07 +01:00
lukaszraczyloandGitHub 4c0af68f60 Update go.mod and go.sum (#68) 2026-07-21 06:58:04 +01:00
lukaszraczyloandGitHub a0e37ec5d8 Update go.mod and go.sum (#67) 2026-07-20 07:24:30 +01:00
lukaszraczyloandGitHub e0ff007e2a Update go.mod and go.sum (#66) 2026-07-19 07:05:12 +01:00
lukaszraczyloandGitHub 390e199ae0 Update go.mod and go.sum (#65) 2026-07-11 05:06:38 +01:00
lukaszraczyloandGitHub 5919e14c18 Update go.mod and go.sum (#64) 2026-07-09 05:25:57 +01:00
lukaszraczyloandGitHub fc97f34cea Update go.mod and go.sum (#63) 2026-07-08 05:08:51 +01:00
lukaszraczyloandGitHub fb1fa9e255 Update go.mod and go.sum (#62) 2026-07-07 05:26:55 +01:00
lukaszraczyloandGitHub ebb2365a88 Update go.mod and go.sum (#61) 2026-07-01 05:40:41 +01:00
lukaszraczyloandGitHub 3b9637bd37 Update go.mod and go.sum (#60) 2026-06-30 05:33:10 +01:00
lukaszraczyloandGitHub ae90f09aef Update go.mod and go.sum (#59) 2026-06-29 05:40:48 +01:00
lukaszraczyloandGitHub 8bcce1bff8 Update go.mod and go.sum (#58) 2026-06-28 05:38:46 +01:00
lukaszraczyloandGitHub 7ce5b71406 Update go.mod and go.sum (#57) 2026-06-27 05:28:30 +01:00
lukaszraczyloandGitHub 3a713a6388 Update go.mod and go.sum (#56) 2026-06-25 05:33:25 +01:00
lukaszraczyloandGitHub 8adec76ce8 Update go.mod and go.sum (#55) 2026-06-24 05:32:57 +01:00
lukaszraczylo 6a41222b34 docs: fix flag defaults, drop phantom annotations, add chart README
Correct two wrong CLI flag defaults (leader-elect, verify-source-freshness) and add missing flags/Helm values. Remove documented-but-nonexistent annotations (excluded-namespaces, namespace-pattern) that silently did nothing, document the real exclude/paused and glob targeting, fix the mirror ownership-check example, and add the missing charts/kubemirror/README.md that README linked to.
2026-06-21 13:27:00 +01:00
lukaszraczylo 28896ab1d6 fix(monitoring): match real controller label values in dashboards and alerts
kubemirror overrides controller names to <Kind>.<version>.<group> (e.g. Secret.v1.), but the dashboards, alerts, and recording rules filtered controller=~"secret|configmap" (the controller-runtime default name), so every panel and alert matched nothing. Switch all selectors to Secret.*|ConfigMap.* and document the naming convention.
2026-06-21 13:26:49 +01:00
lukaszraczylo 6d27d7dfe7 fix(cmd): log the actual build version on startup
The startup log hardcoded version="dev", so release builds always reported dev. Use the ldflags-set Version variable. Also modernise interface{} to any.
2026-06-21 13:26:41 +01:00
lukaszraczylo 330666336f feat(controller): add exclude and paused source annotations
exclude="true" opts a resource out of mirroring and tears down its existing mirrors; paused="true" freezes mirrors in place (no updates, no cleanup). Both respected by the source and namespace reconcilers. Also dedup target-namespace resolution via delegation, remove 8 unused annotation constants and their misleading godoc, and silence gosec G101 false positives on base64 test fixtures.
2026-06-21 13:26:33 +01:00
lukaszraczylo 3628929871 refactor(controller): extract shared mirror-deletion and naming helpers
Replace the triplicated ownership-verified delete guard with deleteOwnedMirror, and hand-built controller-name strings with gvkControllerName. Use constants.Domain/ControllerName over magic strings and maps.Copy/Clone for plain copy loops. Behaviour-preserving; tests green.
2026-06-21 13:26:23 +01:00
lukaszraczyloandGitHub c8ca8c41b6 Update go.mod and go.sum (#54) 2026-06-19 05:40:27 +01:00
lukaszraczyloandGitHub e5aa084c14 Update go.mod and go.sum (#53) 2026-06-18 05:38:47 +01:00
lukaszraczyloandGitHub 417258630a Update go.mod and go.sum (#52) 2026-06-13 05:37:25 +01:00
lukaszraczyloandGitHub 778be24c13 Update go.mod and go.sum (#51) 2026-06-11 05:37:49 +01:00
lukaszraczyloandGitHub d2b8ab4a2d Update go.mod and go.sum (#50) 2026-06-09 05:31:57 +01:00
lukaszraczyloandGitHub ea71c1e839 Update go.mod and go.sum (#49) 2026-06-08 05:38:43 +01:00
lukaszraczyloandGitHub 2fc7d23d40 Update go.mod and go.sum (#48) 2026-06-04 05:38:32 +01:00
lukaszraczyloandGitHub 557c6cfc9b Update go.mod and go.sum (#47) 2026-06-01 05:40:57 +01:00
lukaszraczyloandGitHub 3179a25e41 Update go.mod and go.sum (#46) 2026-05-31 05:38:15 +01:00
lukaszraczyloandGitHub 711713bf76 Update go.mod and go.sum (#45) 2026-05-30 05:28:13 +01:00
lukaszraczyloandGitHub 0e8a9fd0a0 Update go.mod and go.sum (#44) 2026-05-23 05:23:34 +01:00
22 changed files with 894 additions and 481 deletions
+3 -1
View File
@@ -32,7 +32,9 @@ jobs:
- name: Install dependencies
run: go mod download
- uses: engineerd/[email protected]
- uses: helm/[email protected]
with:
version: v0.24.0
- name: Run e2e tests
run: |
+5 -1
View File
@@ -1,5 +1,9 @@
# Build stage
FROM golang:1.25-alpine AS builder
FROM golang:1.26-alpine AS builder
# Populated by buildx per platform; without this declaration the
# GOARCH=${TARGETARCH:-amd64} below silently builds amd64 for every platform.
ARG TARGETARCH
# Install ca-certificates for HTTPS support
RUN apk add --no-cache ca-certificates
+40 -3
View File
@@ -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 (safety net; drift repair is event-driven) | `1h` | `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, a safety net for missed watch events (default: 1h)
- `--verify-source-freshness` - Verify cache against a direct API read before mirroring (default: false)
**Namespace Filtering:**
- `--excluded-namespaces string` - Comma-separated exclusion list
+83
View File
@@ -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` | `"1h"` | Cache resync period, safety net only (`--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.
+1 -3
View File
@@ -43,9 +43,7 @@ spec:
- --worker-threads={{ .Values.controller.workerThreads }}
- --rate-limit-qps={{ .Values.controller.rateLimitQPS }}
- --rate-limit-burst={{ .Values.controller.rateLimitBurst }}
{{- if .Values.controller.verifySourceFreshness }}
- --verify-source-freshness=true
{{- end }}
- --verify-source-freshness={{ .Values.controller.verifySourceFreshness }}
{{- if .Values.controller.lazyWatcherInit }}
- --lazy-watcher-init=true
{{- end }}
+3 -2
View File
@@ -54,10 +54,11 @@ controller:
# How often to rediscover available resources in the cluster
discoveryInterval: "5m"
# Cache resync period - how often to refresh all cached resources
# Cache resync period - safety net for missed watch events; mirror drift
# is repaired event-driven, so this can stay long
# Higher values reduce memory churn and API load
# Default: 10m (was 30s in earlier versions)
resyncPeriod: "10m"
resyncPeriod: "1h"
# Resource limits
maxTargets: 100
+11 -9
View File
@@ -108,14 +108,16 @@ func main() {
"QPS rate limit for API server requests.")
flag.IntVar(&rateLimitBurst, "rate-limit-burst", 100,
"Burst limit for API server requests.")
flag.DurationVar(&resyncPeriod, "resync-period", 10*time.Minute,
"Period for resyncing all resources (catches updates missed due to informer cache delays).")
flag.BoolVar(&verifySourceFreshness, "verify-source-freshness", true,
flag.DurationVar(&resyncPeriod, "resync-period", time.Hour,
"Period for the full informer resync. Sources, mirrors, and namespaces "+
"are watched, and mirror drift is repaired event-driven, so the resync "+
"is only a safety net for missed watch events.")
flag.BoolVar(&verifySourceFreshness, "verify-source-freshness", false,
"Verify source resource freshness by comparing cache with direct API read. "+
"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.")
"Converges a few seconds faster when the informer cache lags behind watch "+
"events, at the cost of one direct apiserver GET per reconcile. Off by "+
"default: a stale read self-corrects on the next watch event, and update "+
"conflicts requeue automatically.")
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. "+
@@ -134,7 +136,7 @@ func main() {
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
setupLog.Info("starting kubemirror controller",
"version", "dev",
"version", Version,
"maxTargets", maxTargets,
"workers", workerThreads,
)
@@ -207,7 +209,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 {
+42 -26
View File
@@ -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.
+38 -38
View File
@@ -3,14 +3,14 @@ module github.com/lukaszraczylo/kubemirror
go 1.26.0
require (
github.com/go-logr/logr v1.4.3
github.com/lukaszraczylo/oss-telemetry v0.1.7
github.com/stretchr/testify v1.11.1
github.com/go-logr/logr v1.4.4
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/stretchr/testify v1.12.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.36.1
k8s.io/apimachinery v0.36.1
k8s.io/client-go v0.36.1
sigs.k8s.io/controller-runtime v0.24.1
k8s.io/api v0.37.1
k8s.io/apimachinery v0.37.1
k8s.io/client-go v0.37.1
sigs.k8s.io/controller-runtime v0.25.2
)
require (
@@ -20,22 +20,22 @@ require (
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.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/swag v0.26.0 // indirect
github.com/go-openapi/swag/cmdutils v0.26.0 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/fileutils v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/mangling v0.26.0 // indirect
github.com/go-openapi/swag/netutils v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.1 // indirect
github.com/go-openapi/swag v0.29.1 // indirect
github.com/go-openapi/swag/cmdutils v0.29.1 // indirect
github.com/go-openapi/swag/conv v0.29.1 // indirect
github.com/go-openapi/swag/fileutils v0.29.1 // indirect
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
github.com/go-openapi/swag/loading v0.29.1 // indirect
github.com/go-openapi/swag/mangling v0.29.1 // indirect
github.com/go-openapi/swag/netutils v0.29.1 // indirect
github.com/go-openapi/swag/pools v0.29.1 // indirect
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -43,34 +43,34 @@ require (
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_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // 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.55.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/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.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.36.1 // indirect
k8s.io/apiextensions-apiserver v0.37.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af // indirect
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 // 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.4.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+88 -88
View File
@@ -16,46 +16,46 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/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.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI=
github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0=
github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU=
github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU=
github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ=
github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0=
github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c=
github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-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.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38=
github.com/go-openapi/jsonreference v1.0.1/go.mod h1:dYplQXa6p5lXprLcJ8LE2iU7vNpXsAHDQ5ZAgL+Qx3A=
github.com/go-openapi/swag v0.29.1 h1:C6EeWzUwQtcWEhE9eqBdUubGXxhWY4PlzHMLD7kLaiQ=
github.com/go-openapi/swag v0.29.1/go.mod h1:BzxEXKiPlSXRsRTv1KSBF/BpGKHxA/YciCnr4tv9bvA=
github.com/go-openapi/swag/cmdutils v0.29.1 h1:3DorPGfUdE80BogKY22EzoHBcHMrkVomZMoV7kS4ANY=
github.com/go-openapi/swag/cmdutils v0.29.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA=
github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM=
github.com/go-openapi/swag/fileutils v0.29.1 h1:ZcPzMceVhU1WPbK6N1G6sNQKdd1CWJlf3cA08UHuoM0=
github.com/go-openapi/swag/fileutils v0.29.1/go.mod h1:/wofKYckbtRl2p3+EwQsosie5CT1B38+dQ+PS579BzI=
github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c=
github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg=
github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc=
github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw=
github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I=
github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s=
github.com/go-openapi/swag/netutils v0.29.1 h1:IjIvdEP5duKcghFqJEPSUraRnkKYHoM65kTluTu+Jb4=
github.com/go-openapi/swag/netutils v0.29.1/go.mod h1:DUde7x4Bx00k5jYl2AdRpNAO0m7atUvD2x6X+bWkbno=
github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg=
github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc=
github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0=
github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc=
github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M=
github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE=
github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk=
github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4=
github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q=
github.com/go-openapi/testify/v2 v2.6.1/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/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
@@ -71,16 +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.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/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.1.7 h1:+lq9syVPMFsZaaSHGES/sYCOKa+Hqi7jyNEXk0soS2o=
github.com/lukaszraczylo/oss-telemetry v0.1.7/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
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=
@@ -98,24 +98,24 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
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_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.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
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=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -126,30 +126,30 @@ 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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/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.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY=
k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo=
k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks=
k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8=
k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA=
k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8=
k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0=
k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU=
k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM=
k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M=
k8s.io/apiextensions-apiserver v0.37.0 h1:zRMQ3+/LIE5oZ0tVvXwYHC+dIkSP5cjNWju7AZU1LOI=
k8s.io/apiextensions-apiserver v0.37.0/go.mod h1:HU0PfSBwchHL5iDau6jjt9zU6ryWkDDlaVUiq91NK80=
k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE=
k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw=
k8s.io/client-go v0.37.1 h1:QTv/5ha4jAHtW9qxxVBkQVFBRDb4jHfFopQqqMdc+wM=
k8s.io/client-go v0.37.1/go.mod h1:dnAPtTnCNY38Ho04D2KdY1F4IKausa9UbqaAZKl60SY=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af h1:zLXA2Irn14q2/06WMkxViyr7YCPUO2lJ0QYE9Juy5vA=
k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 h1:z5+pcu1jTyKK5mNTe2/+x+U6Uuv9jRVOJQLaBJJMpeI=
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098/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.25.2 h1:bEkK3PVOIVK9X8QWLGVhJgmFc++47vfT6wakSzAOLsQ=
sigs.k8s.io/controller-runtime v0.25.2/go.mod h1:4QqLdT6z/L6Olj8JJCtvztid4/fnIiYsfaTFScegctc=
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.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo=
sigs.k8s.io/structured-merge-diff/v6 v6.4.0/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=
+6 -1
View File
@@ -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
+11 -11
View File
@@ -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"
}
+12 -12
View File
@@ -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)
+6 -41
View File
@@ -71,31 +71,18 @@ const (
// Annotation because: values can be complex patterns exceeding label limits.
AnnotationTargetNamespaces = Domain + "/target-namespaces"
// AnnotationExclude explicitly excludes a resource from mirroring when "true".
// 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.
// Annotation because: numeric configuration value.
AnnotationMaxTargets = Domain + "/max-targets"
// AnnotationRecreateOnImmutableChange controls delete/recreate behavior.
// When "true", kubemirror will delete and recreate mirrors on immutable field changes.
// Annotation because: configuration flag, not used for filtering.
AnnotationRecreateOnImmutableChange = Domain + "/recreate-on-immutable-change"
// AnnotationPaused on controller deployment pauses all reconciliation when "true".
// 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 Tracking Annotations ---
// These are set by kubemirror on source resources for change detection.
// AnnotationContentHash stores the SHA256 hash of the source resource content.
// Used for efficient change detection without deep comparison.
// Annotation because: computed value (64 chars), may exceed label limits.
AnnotationContentHash = Domain + "/content-hash"
// --- 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.
@@ -129,19 +116,6 @@ const (
// AnnotationSyncStatus stores human-readable sync status ("3/5 synced", etc.).
AnnotationSyncStatus = Domain + "/sync-status"
// AnnotationFailedTargets stores comma-separated list of failed target namespaces.
AnnotationFailedTargets = Domain + "/failed-targets"
// AnnotationWebhookError stores webhook rejection error message for debugging.
AnnotationWebhookError = Domain + "/webhook-error"
// AnnotationTargetNamespaceUID tracks the UID of the target namespace.
// Used for detecting namespace recreation.
AnnotationTargetNamespaceUID = Domain + "/target-namespace-uid"
// AnnotationDeletionAttempts tracks number of failed deletion attempts.
AnnotationDeletionAttempts = Domain + "/deletion-attempts"
// --- Transformation Annotations ---
// These configure resource transformation during mirroring.
@@ -189,13 +163,4 @@ var (
"bootstrap.kubernetes.io/token",
"helm.sh/release.v1",
}
// Default Denied Resource Types
DefaultDeniedResourceTypes = []string{
"events",
"pods",
"replicasets",
"endpoints",
"endpointslices",
}
)
+10 -23
View File
@@ -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)
@@ -272,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 {
@@ -283,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) {
@@ -445,13 +444,7 @@ func applyTransformations(source, mirror runtime.Object, targetNamespace string)
// Save original annotations to restore on failure
originalAnnotations := mirrorObj.GetAnnotations()
var savedAnnotations map[string]string
if originalAnnotations != nil {
savedAnnotations = make(map[string]string, len(originalAnnotations))
for k, v := range originalAnnotations {
savedAnnotations[k] = v
}
}
savedAnnotations := maps.Clone(originalAnnotations)
mirrorAnnotations := mirrorObj.GetAnnotations()
if mirrorAnnotations == nil {
@@ -504,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
+65
View File
@@ -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
}
+2 -4
View File
@@ -149,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
@@ -157,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).
+6 -7
View File
@@ -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")},
+25 -108
View File
@@ -7,7 +7,6 @@ import (
"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"
@@ -130,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 {
@@ -160,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(),
@@ -199,89 +177,28 @@ 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
}
// Validate patterns and log warnings for invalid ones
validationResults, allValid := filter.ValidatePatterns(patterns)
if !allValid {
logger := log.FromContext(ctx)
invalidPatterns := filter.InvalidPatterns(validationResults)
for _, invalid := range invalidPatterns {
logger.Info("invalid glob pattern in target-namespaces annotation, pattern will be skipped",
"pattern", invalid.Pattern,
"error", invalid.Error.Error(),
"source", source.GetName(),
"namespace", source.GetNamespace(),
)
}
// Filter to only valid patterns
var validPatterns []string
for _, result := range validationResults {
if result.Valid {
validPatterns = append(validPatterns, result.Pattern)
}
}
patterns = validPatterns
// If no valid patterns remain, return empty
if len(patterns) == 0 {
return nil, nil
}
}
// Get all namespace info in a single API call (more efficient than 3 separate calls)
nsInfo, err := r.NamespaceLister.ListNamespacesWithLabels(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list namespaces: %w", err)
}
// Resolve target namespaces using the pre-categorized namespace info
targetNamespaces := filter.ResolveTargetNamespaces(
patterns,
nsInfo.All,
nsInfo.AllowMirrors,
nsInfo.OptOut,
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
targets, _, err := r.newSourceReconciler(source.GroupVersionKind()).
resolveTargetNamespaces(ctx, source)
return targets, err
}
// reconcileMirror creates or updates a mirror in the target namespace by
+18
View File
@@ -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
}
+132 -98
View File
@@ -214,6 +214,15 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
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 !slices.Contains(sourceObj.GetFinalizers(), constants.FinalizerName) {
logger.Info("adding finalizer to source resource")
@@ -229,8 +238,9 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
return ctrl.Result{Requeue: true}, nil
}
// Get target namespaces
targetNamespaces, err := r.resolveTargetNamespaces(ctx, sourceObj)
// Get target namespaces (nsInfo is reused for orphan cleanup below so a
// reconcile pass performs a single namespace LIST instead of two)
targetNamespaces, nsInfo, err := r.resolveTargetNamespaces(ctx, sourceObj)
if err != nil {
logger.Error(err, "failed to resolve target namespaces")
if r.CircuitBreaker != nil {
@@ -259,7 +269,7 @@ func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
}
// Clean up orphaned mirrors (namespaces that no longer match the target criteria)
orphanedCount, err := r.cleanupOrphanedMirrors(ctx, sourceObj, targetNamespaces)
orphanedCount, err := r.cleanupOrphanedMirrors(ctx, sourceObj, targetNamespaces, nsInfo)
if err != nil {
logger.Error(err, "failed to cleanup orphaned mirrors")
// Don't fail reconciliation for cleanup errors, just log them
@@ -347,19 +357,12 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
return fmt.Errorf("failed to get existing mirror: %w", err)
}
// If freshness verification is enabled and mirror exists, verify it's fresh too
if err == nil && r.Config.VerifySourceFreshness && r.APIReader != nil {
fresh := &unstructured.Unstructured{}
fresh.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
if apiErr := r.APIReader.Get(ctx, client.ObjectKey{Namespace: targetNs, Name: sourceObj.GetName()}, fresh); apiErr == nil {
if fresh.GetResourceVersion() != existing.GetResourceVersion() {
logger.V(2).Info("mirror cache stale, using fresh API version",
"cachedRV", existing.GetResourceVersion(),
"freshRV", fresh.GetResourceVersion())
existing = fresh
}
}
}
// The cached mirror is intentionally NOT re-verified against the apiserver.
// A stale cache read is harmless here: content drift is detected via the
// source hash annotation, and a stale resourceVersion makes the Update below
// fail with a conflict, which requeues the source. A direct read at this
// point costs one apiserver GET per target namespace on every reconcile
// (sources x targets x resync), which overloads the control plane.
if err == nil {
// Mirror exists - check if it's managed by us
@@ -375,8 +378,19 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
}
if !needsSync {
logger.V(2).Info("mirror is up to date")
return nil
// The source is unchanged since the last sync, but the mirrored
// copy itself may have been edited in place (its bookkeeping
// annotations would still match the source). Rebuild the expected
// mirror in memory and compare content hashes to catch that.
drifted, driftErr := mirrorContentDrifted(source, existing, targetNs)
if driftErr != nil {
return fmt.Errorf("failed to check mirror drift: %w", driftErr)
}
if !drifted {
logger.V(2).Info("mirror is up to date")
return nil
}
logger.Info("mirror content drifted from expected state, repairing")
}
// Update mirror
@@ -418,6 +432,30 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
return nil
}
// mirrorContentDrifted reports whether the live mirror's content diverged from
// what mirroring the source into targetNs should produce. It rebuilds the
// expected mirror in memory (including transformations) and compares content
// hashes, so metadata-only differences never count as drift. This is what
// makes drift repair event-driven instead of depending on the periodic resync.
func mirrorContentDrifted(source runtime.Object, existing *unstructured.Unstructured, targetNs string) (bool, error) {
expected, err := CreateMirror(source, targetNs)
if err != nil {
return false, fmt.Errorf("failed to build expected mirror: %w", err)
}
expectedHash, err := hash.ComputeContentHash(expected)
if err != nil {
return false, fmt.Errorf("failed to hash expected mirror: %w", err)
}
existingHash, err := hash.ComputeContentHash(existing)
if err != nil {
return false, fmt.Errorf("failed to hash existing mirror: %w", err)
}
return expectedHash != existingHash, nil
}
// 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
@@ -445,32 +483,16 @@ func (r *SourceReconciler) deleteAllMirrors(ctx context.Context, sourceObj metav
continue
}
existing := &unstructured.Unstructured{}
existing.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
getErr := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: sourceObj.GetName()}, existing)
if errors.IsNotFound(getErr) {
continue
}
if getErr != nil {
logger.Error(getErr, "failed to fetch potential mirror", "namespace", ns)
deleteErrs = append(deleteErrs, fmt.Errorf("get mirror %s/%s: %w", ns, sourceObj.GetName(), getErr))
continue
}
if !IsManagedByUs(existing) {
continue
}
srcNs, srcName, _, found := GetSourceReference(existing)
if !found || srcNs != sourceObj.GetNamespace() || srcName != sourceObj.GetName() {
continue
}
if delErr := r.Delete(ctx, existing); delErr != nil && !errors.IsNotFound(delErr) {
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
}
deleteCount++
if outcome == mirrorDeleted {
deleteCount++
}
}
logger.Info("deleted mirrors", "count", deleteCount, "errors", len(deleteErrs))
@@ -482,13 +504,17 @@ func (r *SourceReconciler) deleteAllMirrors(ctx context.Context, sourceObj metav
// cleanupOrphanedMirrors removes mirrors that exist but are no longer in the target list.
// This handles cases where target-namespaces annotation changes (e.g., "all" → "all-labeled" or "app-*" → "prod-*").
func (r *SourceReconciler) cleanupOrphanedMirrors(ctx context.Context, sourceObj metav1.Object, targetNamespaces []string) (int, error) {
// The caller passes the NamespaceInfo it already listed during target
// resolution; a nil nsInfo triggers a fresh list as a fallback.
func (r *SourceReconciler) cleanupOrphanedMirrors(ctx context.Context, sourceObj metav1.Object, targetNamespaces []string, nsInfo *NamespaceInfo) (int, error) {
logger := log.FromContext(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)
if nsInfo == nil {
var err error
nsInfo, err = r.NamespaceLister.ListNamespacesWithLabels(ctx)
if err != nil {
return 0, fmt.Errorf("failed to list namespaces: %w", err)
}
}
allNamespaces := nsInfo.All
@@ -516,62 +542,39 @@ 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
}
// resolveTargetNamespaces determines which namespaces should receive mirrors.
func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceObj metav1.Object) ([]string, error) {
// It also returns the NamespaceInfo it listed (nil when resolution ended before
// listing) so callers can reuse it instead of issuing a second namespace LIST.
func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceObj metav1.Object) ([]string, *NamespaceInfo, error) {
annotations := sourceObj.GetAnnotations()
if annotations == nil {
return nil, nil
return nil, nil, nil
}
targetNsAnnotation := annotations[constants.AnnotationTargetNamespaces]
if targetNsAnnotation == "" {
return nil, nil
return nil, nil, nil
}
// Parse patterns
patterns := filter.ParseTargetNamespaces(targetNsAnnotation)
if len(patterns) == 0 {
return nil, nil
return nil, nil, nil
}
// Validate patterns and log warnings for invalid ones
@@ -599,14 +602,14 @@ func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceOb
// If no valid patterns remain, return empty
if len(patterns) == 0 {
return nil, nil
return nil, 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)
return nil, nil, fmt.Errorf("failed to list namespaces: %w", err)
}
// Resolve target namespaces using the pre-categorized namespace info
@@ -624,7 +627,7 @@ func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceOb
targetNamespaces = targetNamespaces[:r.Config.MaxTargetsPerResource]
}
return targetNamespaces, nil
return targetNamespaces, nsInfo, nil
}
// updateLastSyncStatus updates the source resource's sync-status annotation.
@@ -662,7 +665,10 @@ func isBlacklistedSecret(obj *unstructured.Unstructured) bool {
return slices.Contains(constants.BlacklistedSecretTypes, secretType)
}
// isEnabledForMirroring checks if a resource has both the label and annotation for mirroring.
// 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()
@@ -676,9 +682,23 @@ 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
}
// 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.
// This allows dynamic controller registration for any discovered resource type.
func (r *SourceReconciler) SetupWithManagerForResourceType(
@@ -689,35 +709,49 @@ 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{}
mirrorObj.SetGroupVersionKind(gvk)
// Create predicates to only watch mirror deletions
mirrorDeletePredicate := predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return false },
UpdateFunc: func(e event.UpdateEvent) bool { return false },
DeleteFunc: func(e event.DeleteEvent) bool { return IsMirrorResource(e.Object) },
GenericFunc: func(e event.GenericEvent) bool { return false },
}
return ctrl.NewControllerManagedBy(mgr).
For(obj).
Named(controllerName).
// Watch mirror resources - when deleted, enqueue source for reconciliation
// Watch mirror resources - deletes trigger recreation, real writes
// trigger drift repair. This keeps mirrors converged without relying
// on the periodic resync.
Watches(
mirrorObj,
handler.EnqueueRequestsFromMapFunc(r.mapMirrorToSource),
builder.WithPredicates(mirrorDeletePredicate),
builder.WithPredicates(mirrorEventPredicate()),
).
Complete(r)
}
// mirrorEventPredicate gates the mirror watch. Deletes of managed mirrors
// enqueue the source so the mirror is recreated. Updates enqueue the source
// so in-place edits to a mirror are repaired event-driven; informer resync
// replays carry an unchanged resourceVersion and are filtered out, so only
// real writes pass. The controller's own mirror writes pass too and converge
// in one extra reconcile that no-ops on the content comparison.
func mirrorEventPredicate() predicate.Funcs {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return false },
UpdateFunc: func(e event.UpdateEvent) bool {
if e.ObjectOld == nil || e.ObjectNew == nil {
return false
}
if e.ObjectOld.GetResourceVersion() == e.ObjectNew.GetResourceVersion() {
return false
}
return IsMirrorResource(e.ObjectNew)
},
DeleteFunc: func(e event.DeleteEvent) bool { return IsMirrorResource(e.Object) },
GenericFunc: func(e event.GenericEvent) bool { return false },
}
}
// mapMirrorToSource maps a mirror resource to its source for reconciliation.
func (r *SourceReconciler) mapMirrorToSource(ctx context.Context, obj client.Object) []reconcile.Request {
// Only process if this is a mirror
+287 -5
View File
@@ -18,6 +18,7 @@ import (
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
@@ -205,6 +206,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 {
@@ -215,6 +246,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
@@ -310,7 +382,7 @@ func TestSourceReconciler_resolveTargetNamespaces(t *testing.T) {
},
}
got, err := r.resolveTargetNamespaces(context.Background(), sourceObj)
got, _, err := r.resolveTargetNamespaces(context.Background(), sourceObj)
if tt.wantError {
require.Error(t, err)
@@ -484,7 +556,7 @@ func BenchmarkResolveTargetNamespaces(b *testing.B) {
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = r.resolveTargetNamespaces(ctx, sourceObj)
_, _, _ = r.resolveTargetNamespaces(ctx, sourceObj)
}
}
@@ -569,7 +641,7 @@ func TestSourceReconciler_cleanupOrphanedMirrors(t *testing.T) {
}
ctx := context.Background()
deletedCount, err := r.cleanupOrphanedMirrors(ctx, sourceObj, targetNamespaces)
deletedCount, err := r.cleanupOrphanedMirrors(ctx, sourceObj, targetNamespaces, nil)
require.NoError(t, err)
assert.Equal(t, 1, deletedCount, "should have deleted 1 orphaned mirror")
@@ -603,7 +675,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",
},
},
@@ -632,7 +704,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",
},
},
@@ -735,6 +807,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
@@ -1155,3 +1274,166 @@ func TestSourceReconciler_updateLastSyncStatus_writesWhenChanged(t *testing.T) {
assert.Equal(t, "reconciled:3,errors:0", source.GetAnnotations()[constants.AnnotationSyncStatus])
mockClient.AssertCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything)
}
// countingReader is a client.Reader that counts direct API reads. It stands in
// for the manager's APIReader so tests can assert the reconcile hot path never
// bypasses the informer cache.
type countingReader struct {
gets int
lists int
}
func (c *countingReader) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
c.gets++
return errors.NewNotFound(schema.GroupResource{Resource: "secrets"}, key.Name)
}
func (c *countingReader) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
c.lists++
return nil
}
// newMirrorTestSource builds an unstructured Secret source enabled for mirroring.
func newMirrorTestSource(t *testing.T) *unstructured.Unstructured {
t.Helper()
source := &unstructured.Unstructured{}
source.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "Secret"})
source.SetNamespace("default")
source.SetName("test-secret")
source.SetLabels(map[string]string{constants.LabelEnabled: "true"})
source.SetAnnotations(map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "all",
})
require.NoError(t, unstructured.SetNestedField(source.Object, "Opaque", "type"))
return source
}
// TestSourceReconciler_reconcileMirror_NoDirectAPIReads is a load regression
// test: with an up-to-date mirror in the cache, reconcileMirror must complete
// without a single direct API read. A per-target APIReader.Get here multiplies
// into (sources x target namespaces) apiserver GETs on every resync and has
// melted a control-plane node before.
func TestSourceReconciler_reconcileMirror_NoDirectAPIReads(t *testing.T) {
source := newMirrorTestSource(t)
mirrorObj, err := CreateMirror(source, "target-ns")
require.NoError(t, err)
mirror, ok := mirrorObj.(*unstructured.Unstructured)
require.True(t, ok, "unstructured source must produce an unstructured mirror")
mirror.SetResourceVersion("100")
mockClient := new(MockClient)
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "target-ns", Name: "test-secret"}, mock.Anything).
Return(nil, mirror)
reader := &countingReader{}
r := &SourceReconciler{
Client: mockClient,
APIReader: reader,
Config: &config.Config{VerifySourceFreshness: true},
GVK: source.GroupVersionKind(),
}
require.NoError(t, r.reconcileMirror(context.Background(), source, source, "target-ns"))
assert.Zero(t, reader.gets, "steady-state mirror reconcile must not GET from the apiserver directly")
assert.Zero(t, reader.lists, "steady-state mirror reconcile must not LIST from the apiserver directly")
mockClient.AssertNotCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything)
}
// TestSourceReconciler_getSourceWithFreshness_DefaultNoDirectRead locks the
// default behaviour: freshness verification is opt-in, so the default config
// must serve the source straight from the informer cache.
func TestSourceReconciler_getSourceWithFreshness_DefaultNoDirectRead(t *testing.T) {
source := newMirrorTestSource(t)
mockClient := new(MockClient)
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "test-secret"}, mock.Anything).
Return(nil, source)
reader := &countingReader{}
r := &SourceReconciler{
Client: mockClient,
APIReader: reader,
Config: &config.Config{},
GVK: source.GroupVersionKind(),
}
got, err := r.getSourceWithFreshness(context.Background(), client.ObjectKey{Namespace: "default", Name: "test-secret"}, r.GVK)
require.NoError(t, err)
assert.Equal(t, "test-secret", got.GetName())
assert.Zero(t, reader.gets, "default config must not verify freshness against the apiserver")
}
// TestMirrorEventPredicate locks the mirror-watch gating: real writes to a
// mirror enqueue its source for drift repair, while informer resync replays
// (unchanged resourceVersion), non-mirror objects, and creates stay silent.
func TestMirrorEventPredicate(t *testing.T) {
source := newMirrorTestSource(t)
mirrorObj, err := CreateMirror(source, "target-ns")
require.NoError(t, err)
mirror, ok := mirrorObj.(*unstructured.Unstructured)
require.True(t, ok)
mirror.SetResourceVersion("100")
changed := mirror.DeepCopy()
changed.SetResourceVersion("101")
nonMirrorOld := source.DeepCopy()
nonMirrorOld.SetResourceVersion("1")
nonMirrorNew := source.DeepCopy()
nonMirrorNew.SetResourceVersion("2")
p := mirrorEventPredicate()
assert.False(t, p.Create(event.CreateEvent{Object: mirror}),
"mirror create must not enqueue")
assert.True(t, p.Update(event.UpdateEvent{ObjectOld: mirror, ObjectNew: changed}),
"real mirror write must enqueue the source for drift repair")
assert.False(t, p.Update(event.UpdateEvent{ObjectOld: mirror, ObjectNew: mirror}),
"informer resync replay with unchanged resourceVersion must not enqueue")
assert.False(t, p.Update(event.UpdateEvent{ObjectOld: nonMirrorOld, ObjectNew: nonMirrorNew}),
"non-mirror update must not enqueue")
assert.True(t, p.Delete(event.DeleteEvent{Object: mirror}),
"mirror delete must enqueue the source for recreation")
assert.False(t, p.Delete(event.DeleteEvent{Object: source}),
"non-mirror delete must not enqueue")
}
// TestSourceReconciler_reconcileMirror_RepairsDriftedMirror covers in-place
// mirror tampering: the mirrored data is edited but the bookkeeping
// annotations are left intact, so the source-hash check alone would consider
// the mirror up to date. The reconciler must detect the content divergence
// and rewrite the mirror — still without any direct API reads.
func TestSourceReconciler_reconcileMirror_RepairsDriftedMirror(t *testing.T) {
source := newMirrorTestSource(t)
mirrorObj, err := CreateMirror(source, "target-ns")
require.NoError(t, err)
mirror, ok := mirrorObj.(*unstructured.Unstructured)
require.True(t, ok)
mirror.SetResourceVersion("100")
// Tamper with the mirrored content in place; annotations stay intact.
require.NoError(t, unstructured.SetNestedStringMap(mirror.Object,
map[string]string{"tampered-field": "dGFtcGVyZWQ="}, "data"))
mockClient := new(MockClient)
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "target-ns", Name: "test-secret"}, mock.Anything).
Return(nil, mirror)
mockClient.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil)
reader := &countingReader{}
r := &SourceReconciler{
Client: mockClient,
APIReader: reader,
Config: &config.Config{},
GVK: source.GroupVersionKind(),
}
require.NoError(t, r.reconcileMirror(context.Background(), source, source, "target-ns"))
mockClient.AssertCalled(t, "Update", mock.Anything, mock.Anything, mock.Anything)
assert.Zero(t, reader.gets, "drift repair must not read from the apiserver directly")
}