Compare commits

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

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

Opt out via any of:
  DO_NOT_TRACK=1
  OSS_TELEMETRY_DISABLED=1
  KUBEMIRROR_DISABLE_TELEMETRY=1
2026-05-21 03:05:58 +01:00
lukaszraczyloandGitHub 1836381d9d Update go.mod and go.sum (#41) 2026-05-20 05:33:53 +01:00
lukaszraczyloandGitHub 9240167386 Update go.mod and go.sum (#40) 2026-05-13 05:26:51 +01:00
lukaszraczyloandGitHub 5e360d80c6 Update go.mod and go.sum (#39) 2026-05-12 05:23:43 +01:00
lukaszraczyloandGitHub e5a0846165 Update go.mod and go.sum (#38) 2026-05-10 05:28:40 +01:00
lukaszraczyloandGitHub 64b14b84e0 Update go.mod and go.sum (#37) 2026-05-09 05:16:48 +01:00
lukaszraczyloandGitHub 2a1575f5c9 Update go.mod and go.sum (#36) 2026-05-08 05:11:10 +01:00
lukaszraczyloandGitHub 8feb93146b Update go.mod and go.sum (#35) 2026-05-06 05:22:35 +01:00
18 changed files with 616 additions and 403 deletions
+53 -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 | `10m` | `30m` |
| `controller.verifySourceFreshness` | Verify cache against a direct API read before mirroring | `false` | `true`, `false` |
| **Namespace Filtering** | | | |
| `controller.excludedNamespaces` | Comma-separated namespace exclusion list | `""` | `kube-system,kube-public,kube-node-lease` |
| `controller.includedNamespaces` | Comma-separated namespace inclusion list | `""` | `app-*,prod-*` |
@@ -579,15 +612,19 @@ When running the binary directly:
**Resource Discovery:**
- `--resource-types string` - Comma-separated list (e.g., `Secret.v1,ConfigMap.v1,Ingress.v1.networking.k8s.io`)
- `--discovery-interval duration` - Rediscovery interval (default: 5m)
- `--discovery-interval duration` - Rediscovery interval for auto-discovery mode (default: 5m)
- `--lazy-watcher-init` - Only create watchers for resource types actually in use; lowers memory (default: false)
- `--watcher-scan-interval duration` - Scan interval for new types in lazy mode (default: 5m)
**Performance & Limits:**
- `--leader-elect` - Enable leader election (default: true)
- `--leader-elect` - Enable leader election (default: false)
- `--leader-election-id string` - Leader election lease name (default: kubemirror-controller-leader)
- `--max-targets int` - Max mirrors per source (default: 100)
- `--worker-threads int` - Concurrent workers (default: 5)
- `--rate-limit-qps float32` - API rate limit (default: 50.0)
- `--rate-limit-burst int` - API burst limit (default: 100)
- `--verify-source-freshness` - Verify cache freshness before mirroring (default: false)
- `--resync-period duration` - Full cache resync period (default: 10m)
- `--verify-source-freshness` - Verify cache against a direct API read before mirroring (default: true)
**Namespace Filtering:**
- `--excluded-namespaces string` - Comma-separated exclusion list
@@ -970,6 +1007,19 @@ git push origin v0.2.0
- [Helm Chart Documentation](charts/kubemirror/README.md) - Kubernetes deployment via Helm
- [GitHub Repository](https://github.com/lukaszraczylo/kubemirror) - Source code and issue tracker
## Telemetry
On startup this controller sends a single anonymous adoption ping — project
name, version, timestamp; no identifiers, no Kubernetes object data, no
cluster metadata. Fire-and-forget with a 2-second timeout; cannot block
startup or panic.
See **[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry)**
for the exact wire format, source, and full opt-out documentation.
Quick opt-out: set any of `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1`,
or `KUBEMIRROR_DISABLE_TELEMETRY=1`.
## License
See [LICENSE](LICENSE) file for details.
+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` | `"10m"` | Cache resync period (`--resync-period`). |
| `controller.maxTargets` | `100` | Max target namespaces per resource (`--max-targets`). |
| `controller.workerThreads` | `5` | Concurrent reconcile workers (`--worker-threads`). |
| `controller.rateLimitQPS` | `50.0` | API QPS limit (`--rate-limit-qps`). |
| `controller.rateLimitBurst` | `100` | API burst limit (`--rate-limit-burst`). |
| `controller.verifySourceFreshness` | `false` | Verify cache against a direct API read (`--verify-source-freshness`). |
| `controller.lazyWatcherInit` | `false` | Only watch resource types actually in use (`--lazy-watcher-init`). Lowers memory; recommended for production. |
| `controller.watcherScanInterval` | `"5m"` | Scan interval for new types in lazy mode (`--watcher-scan-interval`). |
| `controller.excludedNamespaces` | `""` | Comma-separated namespaces never mirrored to (`--excluded-namespaces`). |
| `controller.includedNamespaces` | `""` | Comma-separated namespace patterns to include (`--included-namespaces`). |
| `service.type` | `ClusterIP` | Service type. |
| `service.metricsPort` | `8080` | Metrics port. |
| `service.healthPort` | `8081` | Health-probe port. |
| `resources.requests` | `100m` CPU / `128Mi` | Container requests. |
| `resources.limits` | `500m` CPU / `512Mi` | Container limits. |
| `nodeSelector` / `tolerations` / `affinity` | `{}` / `[]` / `{}` | Standard scheduling controls. |
| `priorityClassName` | `""` | Pod priority class. |
> **Memory tip:** setting `controller.resourceTypes` to the exact types you mirror
> (e.g. `["Secret.v1", "ConfigMap.v1"]`), or enabling `controller.lazyWatcherInit`,
> avoids creating watchers for unused resource types and cuts memory use
> substantially versus full auto-discovery.
## Usage
After install, mark a source resource for mirroring and opt a target namespace
in. See the [project README](../../README.md) and [examples](../../examples/) for
the full annotation/label reference and worked scenarios.
+10 -2
View File
@@ -27,8 +27,14 @@ import (
"github.com/lukaszraczylo/kubemirror/pkg/controller"
"github.com/lukaszraczylo/kubemirror/pkg/discovery"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
telemetry "github.com/lukaszraczylo/oss-telemetry"
)
// Version is the build version. Set via ldflags during build:
//
// -X main.Version=v1.2.3
var Version = "dev"
var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
@@ -57,6 +63,8 @@ func makeCacheSyncChecker(c cache.Cache, ctx context.Context, logger logr.Logger
}
func main() {
telemetry.Send("kubemirror", Version)
var (
metricsAddr string
probeAddr string
@@ -126,7 +134,7 @@ func main() {
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
setupLog.Info("starting kubemirror controller",
"version", "dev",
"version", Version,
"maxTargets", maxTargets,
"workers", workerThreads,
)
@@ -199,7 +207,7 @@ func main() {
// This can reduce memory consumption by 50-70% by removing:
// - managedFields (often several KB per resource)
// - large annotations like kubectl.kubernetes.io/last-applied-configuration
transformFunc := func(obj interface{}) (interface{}, error) {
transformFunc := func(obj any) (any, error) {
// Type assert to unstructured
u, ok := obj.(*unstructured.Unstructured)
if !ok {
+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.
+32 -31
View File
@@ -3,13 +3,14 @@ module github.com/lukaszraczylo/kubemirror
go 1.26.0
require (
github.com/go-logr/logr v1.4.3
github.com/go-logr/logr v1.4.4
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.36.0
k8s.io/apimachinery v0.36.0
k8s.io/client-go v0.36.0
sigs.k8s.io/controller-runtime v0.24.0
k8s.io/api v0.36.2
k8s.io/apimachinery v0.36.2
k8s.io/client-go v0.36.2
sigs.k8s.io/controller-runtime v0.24.1
)
require (
@@ -21,20 +22,20 @@ require (
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.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.0 // indirect
github.com/go-openapi/swag v0.27.3 // indirect
github.com/go-openapi/swag/cmdutils v0.27.3 // indirect
github.com/go-openapi/swag/conv v0.27.3 // indirect
github.com/go-openapi/swag/fileutils v0.27.3 // indirect
github.com/go-openapi/swag/jsonutils v0.27.3 // indirect
github.com/go-openapi/swag/loading v0.27.3 // indirect
github.com/go-openapi/swag/mangling v0.27.3 // indirect
github.com/go-openapi/swag/netutils v0.27.3 // indirect
github.com/go-openapi/swag/pools v0.27.3 // indirect
github.com/go-openapi/swag/stringutils v0.27.3 // indirect
github.com/go-openapi/swag/typeutils v0.27.3 // indirect
github.com/go-openapi/swag/yamlutils v0.27.3 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -42,10 +43,10 @@ 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_golang v1.24.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/prometheus/common v0.70.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -53,23 +54,23 @@ require (
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.53.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.36.0 // indirect
k8s.io/apiextensions-apiserver v0.36.2 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260504175024-7bfe71ffdc10 // indirect
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+76 -74
View File
@@ -18,44 +18,44 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/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.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/swag v0.27.3 h1:i6oVKkGZeFgETHMiBHGtj9gIQ1aLtWDdJnT/SRZeets=
github.com/go-openapi/swag v0.27.3/go.mod h1:qEXs3GcyyQTDCFQ4ykqnLPDh8qT+zBcjbVWdxCAW0Us=
github.com/go-openapi/swag/cmdutils v0.27.3 h1:sjuL0TvW81i9R9GRMO/fy+c3mOW+7zxRYwy/7fobZt4=
github.com/go-openapi/swag/cmdutils v0.27.3/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU=
github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ=
github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE=
github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc=
github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0=
github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE=
github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg=
github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/netutils v0.27.3 h1:IoBvfCoprsE6E87kAIm9basnISqDDqB79mJ8MN+f5PU=
github.com/go-openapi/swag/netutils v0.27.3/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk=
github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI=
github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4=
github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs=
github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
@@ -71,14 +71,16 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -96,14 +98,14 @@ 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_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
@@ -126,24 +128,24 @@ 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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
@@ -157,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.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0=
k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug=
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4=
k8s.io/apiextensions-apiserver v0.36.2/go.mod h1:cL1tBWe8XSaP1H30iWKGo7hf6iAUUUJPEU70dskmAnA=
k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI=
k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260504175024-7bfe71ffdc10 h1:WTqpjlNkK8rfPghVhuBPxEswk9Y99sUpiMAfOClcuNM=
k8s.io/kube-openapi v0.0.0-20260504175024-7bfe71ffdc10/go.mod h1:Cyq7UE0QtGe+Zo+/6XFrxiS4Mq0tLyQEONkFzSkfp9o=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/controller-runtime v0.24.0 h1:Ck6N2LdS8Lovy1o25BB4r1xjvLEKUl1s2o9kU+KWDE4=
sigs.k8s.io/controller-runtime v0.24.0/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.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")},
+24 -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,27 @@ 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
return r.newSourceReconciler(source.GroupVersionKind()).
resolveTargetNamespaces(ctx, source)
}
// 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
}
+40 -58
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")
@@ -445,32 +454,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))
@@ -516,41 +509,16 @@ func (r *SourceReconciler) cleanupOrphanedMirrors(ctx context.Context, sourceObj
continue
}
// Check if a mirror exists in this namespace
mirror := &unstructured.Unstructured{}
mirror.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
mirror.SetNamespace(ns)
mirror.SetName(sourceObj.GetName())
err := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: sourceObj.GetName()}, mirror)
if errors.IsNotFound(err) {
// No mirror exists, nothing to clean up
continue
}
outcome, err := deleteOwnedMirror(ctx, r.Client, sourceUnstructured.GroupVersionKind(),
ns, sourceObj.GetName(), sourceObj.GetNamespace(), sourceObj.GetName())
if err != nil {
logger.Error(err, "failed to check for mirror", "namespace", ns)
continue
}
// Verify this is actually our mirror (not someone else's resource with the same name)
if !IsManagedByUs(mirror) {
continue
}
// Verify this mirror points to our source
srcNs, srcName, _, found := GetSourceReference(mirror)
if !found || srcNs != sourceObj.GetNamespace() || srcName != sourceObj.GetName() {
continue
}
// This is an orphaned mirror - delete it
if err := r.Delete(ctx, mirror); err != nil {
logger.Error(err, "failed to delete orphaned mirror", "namespace", ns)
continue
}
deletedCount++
logger.V(1).Info("deleted orphaned mirror", "namespace", ns)
if outcome == mirrorDeleted {
deletedCount++
logger.V(1).Info("deleted orphaned mirror", "namespace", ns)
}
}
return deletedCount, nil
@@ -662,7 +630,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 +647,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,10 +674,7 @@ func (r *SourceReconciler) SetupWithManagerForResourceType(
obj := &unstructured.Unstructured{}
obj.SetGroupVersionKind(gvk)
// Create unique controller name including version and group to avoid collisions
// e.g., "HorizontalPodAutoscaler.v1.autoscaling" or "Secret.v1." (empty group for core resources)
// This matches the naming convention used by mirror reconcilers
controllerName := gvk.Kind + "." + gvk.Version + "." + gvk.Group
controllerName := gvkControllerName(gvk, false)
// Create mirror object for watching
mirrorObj := &unstructured.Unstructured{}
+120 -2
View File
@@ -205,6 +205,36 @@ func TestIsEnabledForMirroring(t *testing.T) {
},
want: false,
},
{
name: "excluded overrides enabled label and sync annotation",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationExclude: "true",
},
},
},
want: false,
},
{
name: "exclude set to false does not disable mirroring",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationExclude: "false",
},
},
},
want: true,
},
}
for _, tt := range tests {
@@ -215,6 +245,47 @@ func TestIsEnabledForMirroring(t *testing.T) {
}
}
func TestIsPaused(t *testing.T) {
tests := []struct {
obj metav1.Object
name string
want bool
}{
{
name: "paused true",
obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{constants.AnnotationPaused: "true"},
}},
want: true,
},
{
name: "paused false",
obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{constants.AnnotationPaused: "false"},
}},
want: false,
},
{
name: "annotation absent",
obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{constants.AnnotationSync: "true"},
}},
want: false,
},
{
name: "no annotations",
obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isPaused(tt.obj))
})
}
}
func TestSourceReconciler_resolveTargetNamespaces(t *testing.T) {
tests := []struct {
name string
@@ -603,7 +674,7 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
constants.FinalizerName,
},
},
"data": map[string]interface{}{
"data": map[string]interface{}{ //nolint:gosec // base64 test fixture, not a real credential
"password": "c2VjcmV0",
},
},
@@ -632,7 +703,7 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
constants.AnnotationSourceUID: "source-uid-123",
},
},
"data": map[string]interface{}{
"data": map[string]interface{}{ //nolint:gosec // base64 test fixture, not a real credential
"password": "c2VjcmV0",
},
},
@@ -735,6 +806,53 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
mockLister.AssertExpectations(t)
}
func TestSourceReconciler_Reconcile_Paused_LeavesMirrorsUntouched(t *testing.T) {
// A paused source must early-return: no List, Create, Delete, or Update.
// The MockClient panics on any un-mocked call, so wiring only the source Get
// proves Reconcile touches nothing else.
source := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]any{
"name": "test-secret",
"namespace": "default",
"uid": "source-uid-123",
"labels": map[string]any{
constants.LabelEnabled: "true",
},
"annotations": map[string]any{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "all",
constants.AnnotationPaused: "true",
},
"finalizers": []any{constants.FinalizerName},
},
"data": map[string]any{"password": "c2VjcmV0"}, //nolint:gosec // base64 test fixture, not a real credential
},
}
mockClient := new(MockClient)
// Only the source read is expected; nothing else.
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "test-secret"}, mock.Anything).
Return(nil, source).Once()
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: filter.NewNamespaceFilter(nil, nil),
GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
}
result, err := r.Reconcile(context.Background(),
ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "test-secret"}})
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
mockClient.AssertExpectations(t)
}
func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T) {
// Scenario: annotation changes from "app-*" → "prod-*"
// Before: mirrors in app-1, app-2, app-3