initial commit

This commit is contained in:
2025-12-25 22:10:57 +00:00
commit 8adb52608f
46 changed files with 7570 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
name: CI
on:
pull_request:
branches:
- main
push:
branches:
- main
paths-ignore:
- "**.md"
- "docs/**"
- "examples/**"
permissions:
contents: read
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache: true
- name: Go mod tidy
run: go mod tidy && git diff --exit-code
- name: Go mod verify
run: go mod verify
- name: Format check
run: |
go fmt ./...
git diff --exit-code
- name: Vet
run: go vet ./...
- name: Test
run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.out
flags: unittests
name: codecov-kubemirror
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache: true
- name: Install staticcheck
run: go install honnef.co/go/tools/cmd/staticcheck@latest
- name: Install gosec
run: go install github.com/securego/gosec/v2/cmd/gosec@latest
- name: Install deadcode
run: go install golang.org/x/tools/cmd/deadcode@latest
- name: Run staticcheck
run: staticcheck ./...
- name: Run gosec
run: gosec -exclude=G115 ./...
- name: Run deadcode
run: deadcode ./...
bench:
name: Benchmark
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache: true
- name: Run benchmarks
run: go test -race -bench=. -benchmem ./... | tee benchmark.txt
- name: Upload benchmark results
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: benchmark.txt
+68
View File
@@ -0,0 +1,68 @@
name: Release
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
id-token: write
contents: write
packages: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache: true
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-helm-chart:
needs: release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get release version
id: version
run: |
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
VERSION=${VERSION#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Trigger helm-charts release
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
run: |
gh api repos/lukaszraczylo/helm-charts/dispatches -f event_type=release-chart -f client_payload[chart_name]=kubemirror -f client_payload[version]="$RELEASE_VERSION" -f client_payload[source_repo]=lukaszraczylo/kubemirror -f client_payload[chart_path]=charts/kubemirror
+49
View File
@@ -0,0 +1,49 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.txt
coverage.html
# Go workspace file
go.work
# Build output
bin/
dist/
kubemirror
/kubemirror
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# macOS
.DS_Store
#
CLAUDE.md
.claude/
# Kubernetes
kubeconfig
*.kubeconfig
# Temporary files
tmp/
temp/
*.tmp
# Logs
*.log
+176
View File
@@ -0,0 +1,176 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
project_name: kubemirror
before:
hooks:
- go mod tidy
- go mod verify
# Note: Helm chart versioning is handled by the helm-charts repository
# triggered by the publish-helm-chart job in .github/workflows/release.yaml
builds:
- id: kubemirror
main: ./cmd/kubemirror
binary: kubemirror
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w
- -X main.Version={{.Version}}
- -X main.Commit={{.Commit}}
- -X main.Date={{.Date}}
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
archives:
- id: default
formats:
- tar.gz
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- .Arch }}
files:
- README.md
- LICENSE
- examples/*
format_overrides:
- goos: windows
format: zip
checksum:
name_template: 'checksums.txt'
algorithm: sha256
changelog:
sort: asc
use: github
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore:'
- '^ci:'
- Merge pull request
- Merge branch
groups:
- title: Features
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
order: 0
- title: Bug Fixes
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
order: 1
- title: Performance Improvements
regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$'
order: 2
- title: Others
order: 999
release:
github:
owner: lukaszraczylo
name: kubemirror
draft: false
prerelease: auto
name_template: "v{{.Version}}"
header: |
## KubeMirror v{{.Version}}
Kubernetes controller for mirroring resources (Secrets, ConfigMaps) across namespaces with automatic synchronization.
### Installation
**Helm (recommended):**
```bash
helm repo add kubemirror https://lukaszraczylo.github.io/helm-charts
helm repo update
helm install kubemirror kubemirror/kubemirror --version {{.Version}}
```
**Helm (from release asset):**
```bash
helm install kubemirror https://github.com/lukaszraczylo/kubemirror/releases/download/v{{.Version}}/kubemirror-{{.Version}}.tgz
```
**Docker:**
```bash
docker pull ghcr.io/lukaszraczylo/kubemirror:{{.Version}}
```
**Binary (Linux/macOS/Windows):**
Download the archive for your platform from the assets below, extract, and run:
```bash
./kubemirror --help
```
### Usage
Apply labels and annotations to resources you want to mirror:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: my-secret
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "app1,app2,app-*"
```
See [examples/](https://github.com/lukaszraczylo/kubemirror/tree/main/examples) for more usage patterns.
dockers_v2:
- ids:
- kubemirror
images:
- "ghcr.io/lukaszraczylo/kubemirror"
tags:
- "{{ .Version }}"
- "latest"
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile.goreleaser
labels:
"org.opencontainers.image.title": "{{ .ProjectName }}"
"org.opencontainers.image.version": "{{ .Version }}"
"org.opencontainers.image.source": "https://github.com/lukaszraczylo/kubemirror"
"org.opencontainers.image.description": "Kubernetes controller for mirroring resources across namespaces"
"org.opencontainers.image.licenses": "MIT"
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
args:
- sign-blob
- "--bundle=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
output: true
docker_signs:
- cmd: cosign
artifacts: images
output: true
args:
- sign
- "${artifact}@${digest}"
- "--yes"
+44
View File
@@ -0,0 +1,44 @@
# Build stage
FROM golang:1.25-alpine AS builder
# Install ca-certificates for HTTPS support
RUN apk add --no-cache ca-certificates
WORKDIR /workspace
# Copy go mod files first for better caching
COPY go.mod go.sum ./
RUN go mod download && go mod verify
# Copy source code
COPY cmd/ cmd/
COPY pkg/ pkg/
# Build the binary with security flags
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build \
-trimpath \
-ldflags="-w -s -extldflags '-static'" \
-tags=netgo \
-o kubemirror \
./cmd/kubemirror
# Runtime stage - using distroless for minimal attack surface
FROM gcr.io/distroless/static:nonroot
LABEL org.opencontainers.image.title="kubemirror"
LABEL org.opencontainers.image.description="Kubernetes controller for mirroring resources across namespaces"
LABEL org.opencontainers.image.source="https://github.com/lukaszraczylo/kubemirror"
LABEL org.opencontainers.image.licenses="MIT"
WORKDIR /
# Copy ca-certificates for HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy the binary from builder
COPY --from=builder /workspace/kubemirror /kubemirror
# Use nonroot user (uid:gid 65532:65532)
USER 65532:65532
ENTRYPOINT ["/kubemirror"]
+12
View File
@@ -0,0 +1,12 @@
# Runtime stage - using distroless for minimal attack surface
FROM gcr.io/distroless/static:nonroot
WORKDIR /
# Copy the binary from goreleaser build
COPY kubemirror /kubemirror
# Use nonroot user (65532)
USER 65532:65532
ENTRYPOINT ["/kubemirror"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Lukasz Raczylo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+147
View File
@@ -0,0 +1,147 @@
# Image URL to use all building/pushing image targets
IMG ?= ghcr.io/lukaszraczylo/kubemirror:latest
IMG_SECONDARY_TAG ?= ""
# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
ifeq (,$(shell go env GOBIN))
GOBIN=$(shell go env GOPATH)/bin
else
GOBIN=$(shell go env GOBIN)
endif
# Setting SHELL to bash allows bash commands to be executed by recipes.
# Options are set to exit when a recipe line exits non-zero or a piped command fails.
SHELL = /usr/bin/env bash -o pipefail
.SHELLFLAGS = -ec
.PHONY: all
all: build
##@ General
.PHONY: help
help: ## Display this help.
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
##@ Development
.PHONY: fmt
fmt: ## Run go fmt against code.
go fmt ./...
.PHONY: vet
vet: ## Run go vet against code.
go vet ./...
.PHONY: lint
lint: ## Run staticcheck, gosec, and other linters.
@command -v staticcheck >/dev/null 2>&1 || { echo "Installing staticcheck..."; go install honnef.co/go/tools/cmd/staticcheck@latest; }
@command -v gosec >/dev/null 2>&1 || { echo "Installing gosec..."; go install github.com/securego/gosec/v2/cmd/gosec@latest; }
@command -v deadcode >/dev/null 2>&1 || { echo "Installing deadcode..."; go install golang.org/x/tools/cmd/deadcode@latest; }
staticcheck ./...
gosec -exclude=G115 ./...
deadcode ./...
.PHONY: test
test: fmt vet ## Run tests.
go test ./... -coverprofile cover.out
.PHONY: test-race
test-race: fmt vet ## Run tests with race detector.
go test -race ./...
.PHONY: test-verbose
test-verbose: fmt vet ## Run tests with verbose output.
go test -v -race ./...
.PHONY: bench
bench: ## Run benchmarks.
go test -race -bench=. -benchmem ./...
.PHONY: cover
cover: test ## Run tests and open coverage in browser.
go tool cover -html=cover.out
##@ Build
.PHONY: build
build: fmt vet ## Build controller binary.
go build -o kubemirror ./cmd/kubemirror
.PHONY: run
run: fmt vet ## Run controller from your host (against current kubeconfig).
go run ./cmd/kubemirror --dry-run=true
.PHONY: clean
clean: ## Clean build artifacts.
rm -f kubemirror cover.out
rm -rf dist/
.PHONY: docker-build
docker-build: test ## Build docker image.
docker build -t ${IMG} .
.PHONY: docker-push
docker-push: ## Push docker image.
docker push ${IMG}
# PLATFORMS defines the target platforms for the manager image
PLATFORMS ?= linux/arm64,linux/amd64
.PHONY: docker-buildx
docker-buildx: ## Build and push docker image for cross-platform support
- docker buildx create --name kubemirror-builder
docker buildx use kubemirror-builder
if [ -z "$(IMG_SECONDARY_TAG)" ]; then \
docker buildx build --push --platform=$(PLATFORMS) --tag ${IMG} .; \
else \
docker buildx build --push --platform=$(PLATFORMS) --tag ${IMG} --tag ${IMG_SECONDARY_TAG} .; \
fi
- docker buildx rm kubemirror-builder
##@ Deployment
ifndef ignore-not-found
ignore-not-found = false
endif
.PHONY: deploy
deploy: ## Deploy controller to the K8s cluster specified in ~/.kube/config.
kubectl apply -k deploy/
.PHONY: undeploy
undeploy: ## Remove controller from the K8s cluster.
kubectl delete -k deploy/ --ignore-not-found=$(ignore-not-found)
.PHONY: logs
logs: ## Show controller logs.
kubectl -n kubemirror-system logs -l app.kubernetes.io/name=kubemirror -f
.PHONY: status
status: ## Show controller status.
kubectl -n kubemirror-system get pods -l app.kubernetes.io/name=kubemirror
kubectl -n kubemirror-system get deployments
kubectl -n kubemirror-system get services
##@ Code Quality
.PHONY: tidy
tidy: ## Run go mod tidy.
go mod tidy
.PHONY: verify
verify: fmt vet lint test-race ## Run all verification steps (format, vet, lint, test with race).
.PHONY: ci
ci: verify bench ## Run full CI pipeline locally.
##@ Release
.PHONY: release-dry
release-dry: ## Run GoReleaser in dry-run mode.
@command -v goreleaser >/dev/null 2>&1 || { echo "Installing goreleaser..."; go install github.com/goreleaser/goreleaser@latest; }
goreleaser release --snapshot --clean
.PHONY: release
release: ## Run GoReleaser (requires tag).
@command -v goreleaser >/dev/null 2>&1 || { echo "Installing goreleaser..."; go install github.com/goreleaser/goreleaser@latest; }
goreleaser release --clean
+373
View File
@@ -0,0 +1,373 @@
# KubeMirror
A Kubernetes controller for automatically mirroring any resource type (Secrets, ConfigMaps, Ingresses, CRDs, etc.) across namespaces with intelligent synchronization.
## Features
- **Universal Resource Support**: Mirror any Kubernetes resource type - Secrets, ConfigMaps, Ingresses, Services, CRDs, and more
- **Auto-Discovery**: Automatically discovers all mirrorable resources in the cluster with periodic refresh
- **Efficient Mirroring**: Mirror resources to specific namespaces, pattern-matched namespaces, or all namespaces
- **Content Change Detection**: Multi-layer strategy (generation field + content hash) to avoid unnecessary syncs
- **API-Friendly**: Cluster-scoped watches with server-side filtering reduce API server load by 90%+
- **Production-Ready**: Leader election, health checks, metrics, graceful shutdown
- **Drift Detection**: Automatically fixes manually modified target resources
- **Pattern Matching**: Support glob patterns like `app-*`, `prod-*`
- **Safety Limits**: Configurable maximum targets, namespace opt-in for "all" mirrors
- **Finalizer-based Cleanup**: Ensures all mirrors are deleted when source is removed
## Quick Start
### Prerequisites
- Kubernetes 1.28+
- kubectl configured
### Installation
#### Using Helm (Recommended)
```bash
# Add the Helm repository
helm repo add lukaszraczylo https://lukaszraczylo.github.io/helm-charts/
helm repo update
# Install kubemirror
helm install kubemirror lukaszraczylo/kubemirror \
--namespace kubemirror-system \
--create-namespace
# Or with custom values
helm install kubemirror lukaszraczylo/kubemirror \
--namespace kubemirror-system \
--create-namespace \
--set controller.maxTargets=200 \
--set controller.workerThreads=10
# Verify installation
helm status kubemirror -n kubemirror-system
kubectl -n kubemirror-system get pods
```
**Development:** To test the local chart during development:
```bash
helm install kubemirror ./charts/kubemirror -n kubemirror-system --create-namespace
```
#### Using kubectl
```bash
# Using kustomize
kubectl apply -k deploy/
# Or apply manifests individually
kubectl apply -f deploy/namespace.yaml
kubectl apply -f deploy/rbac.yaml
kubectl apply -f deploy/deployment.yaml
kubectl apply -f deploy/service.yaml
# Verify controller is running
kubectl -n kubemirror-system get pods
kubectl -n kubemirror-system logs -l app.kubernetes.io/name=kubemirror
```
### Usage
#### Mirror a Secret to specific namespaces
```yaml
apiVersion: v1
kind: Secret
metadata:
name: my-secret
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true" # Required for filtering
annotations:
kubemirror.raczylo.com/sync: "true" # Enable mirroring
kubemirror.raczylo.com/target-namespaces: "app1,app2,app3"
type: Opaque
data:
password: cGFzc3dvcmQ=
```
#### Mirror to pattern-matched namespaces
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: common-config
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "app-*,prod-*"
data:
setting: value
```
#### Mirror to all labeled namespaces
```yaml
apiVersion: v1
kind: Secret
metadata:
name: shared-tls
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "all-labeled"
```
Namespaces must opt-in:
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: my-app
labels:
kubemirror.raczylo.com/allow-mirrors: "true"
```
## Architecture
- **Discovery Manager**: Auto-discovers all mirrorable resource types with periodic refresh
- **Source Reconciler**: Watches labeled resources, creates/updates mirrors
- **Target Reconciler**: Watches mirrored resources, detects drift and orphans
- **Namespace Reconciler**: Watches namespace creation, auto-creates mirrors for patterns
- **Content Hash**: SHA256 of actual content (excludes Kubernetes metadata)
- **Field Indexing**: O(1) lookups for reverse references (target → source)
- **Safety Filtering**: Deny list prevents mirroring dangerous resources (Pods, Events, etc.)
## Configuration
### Helm Chart Values
Key configuration options in `values.yaml`:
```yaml
controller:
# Resource Discovery
resourceTypes: [] # Explicit list (e.g., ["Secret.v1", "ConfigMap.v1"])
# If empty, auto-discovers all mirrorable resources
discoveryInterval: "5m" # How often to rediscover resources (auto-discovery mode)
# Performance & Limits
leaderElect: true # Enable leader election for HA
maxTargets: 100 # Max mirrors per source resource
workerThreads: 5 # Concurrent reconciliation workers
rateLimitQPS: 50.0 # API rate limit (queries per second)
rateLimitBurst: 100 # API burst allowance
# Namespace Filtering
excludedNamespaces: "" # Comma-separated exclusion list
includedNamespaces: "" # Comma-separated inclusion list
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
```
### Command-line Flags
Key flags when running the binary directly:
**Resource Discovery:**
- `--resource-types`: Comma-separated list of resource types (e.g., `Secret.v1,ConfigMap.v1,Ingress.v1.networking.k8s.io`)
- If empty, auto-discovers all mirrorable resources
- `--discovery-interval`: Rediscovery interval for auto-discovery mode (default: 5m)
**Performance & Limits:**
- `--leader-elect`: Enable leader election (default: true)
- `--max-targets`: Limit mirrors per source (default: 100)
- `--worker-threads`: Concurrent workers (default: 5)
- `--rate-limit-qps`: API rate limit (default: 50.0)
- `--rate-limit-burst`: API burst limit (default: 100)
**Namespace Filtering:**
- `--excluded-namespaces`: Comma-separated namespace exclusion list
- `--included-namespaces`: Comma-separated namespace inclusion list
## Resource Auto-Discovery
KubeMirror can automatically discover all mirrorable resources in your cluster, eliminating the need to manually specify resource types.
### How it works
**Auto-Discovery Mode (Default):**
When `resourceTypes` is empty (default), KubeMirror:
1. Scans all available API resources in the cluster
2. Filters for namespaced resources with required verbs (get, list, watch, create, update, delete)
3. Excludes dangerous resources (Pods, Events, Nodes, etc.) using a deny list
4. Periodically rediscovers resources (default: every 5 minutes) to detect new CRDs or resource types
**Explicit Mode:**
Specify exactly which resources to mirror:
```yaml
controller:
resourceTypes:
- "Secret.v1"
- "ConfigMap.v1"
- "Ingress.v1.networking.k8s.io"
- "Middleware.v1alpha1.traefik.io"
```
### Safety Features
Auto-discovery includes built-in safety:
- **Deny List**: Never mirrors Pods, Events, Nodes, Endpoints, Leases, etc.
- **Namespaced Only**: Only discovers namespaced resources (cluster-scoped are excluded)
- **Verb Filtering**: Resources must support all required CRUD operations
- **Opt-In Required**: Resources must have `kubemirror.raczylo.com/enabled: "true"` label
### Monitoring Discovery
View discovered resources in the logs:
```bash
kubectl logs -n kubemirror-system -l app.kubernetes.io/name=kubemirror | grep "resource discovery"
```
## Examples
See the [examples/](examples/) directory for complete working examples including:
- Secrets mirrored to all namespaces
- ConfigMaps mirrored to specific namespaces
- Traefik Middlewares (custom resources) mirroring
- Comprehensive testing scenarios
```bash
# Apply examples
kubectl apply -k examples/
# Watch mirroring in action
kubectl logs -n kubemirror-system -l app.kubernetes.io/name=kubemirror -f
```
## Development
### Using Makefile
```bash
# Run tests
make test
# Run tests with race detector
make test-race
# Run benchmarks
make bench
# Build binary
make build
# Run locally
make run
# Build Docker image
make docker-build
# Run linters
make lint
# Full CI checks
make ci
```
### Manual Commands
```bash
# Run tests
go test ./...
# Run with race detector
go test -race ./...
# Run benchmarks
go test -race -bench=. ./...
# Build binary
go build -o kubemirror ./cmd/kubemirror
# Run locally (against current kubeconfig)
./kubemirror
# Build Docker image
docker build -t ghcr.io/lukaszraczylo/kubemirror:latest .
# Push to registry (requires authentication)
docker push ghcr.io/lukaszraczylo/kubemirror:latest
```
### Release
```bash
# Dry run (test release locally)
make release-dry
# Create release (requires git tag)
git tag -a v0.1.0 -m "Release v0.1.0"
git push origin v0.1.0
# GitHub Actions will automatically build and release
```
## Roadmap
- **Phase 1 (MVP)**: Secrets & ConfigMaps, basic mirroring ✅ **Complete**
- Core reconciliation logic ✅
- Content hash-based change detection ✅
- Pattern matching for namespaces ✅
- Helm chart & deployment manifests ✅
- Comprehensive test suite ✅
- CI/CD with GitHub Actions ✅
- **Phase 2**: Production hardening & observability ✅ **Complete**
- Prometheus metrics dashboard ✅
- Alert rules for common issues ✅
- Recording rules for performance monitoring ✅
- Grafana dashboard with KPIs ✅
- Performance optimization for large clusters (covered by rate limiting & worker threads) ✅
- **Phase 3**: Universal resource support ✅ **Complete**
- Auto-discovery of all resource types ✅
- Support for CRDs, Ingresses, Services, and more ✅
- Periodic rediscovery for dynamic clusters ✅
- Safety filtering and deny lists ✅
- **Phase 4**: Advanced features (Future)
- Cross-namespace reference rewriting
- kubectl plugin for easy management
- Advanced transformation rules
## Monitoring
KubeMirror exposes Prometheus metrics and includes production-ready monitoring resources:
```bash
# Deploy ServiceMonitor and Alert Rules
kubectl apply -f monitoring/servicemonitor.yaml
kubectl apply -f monitoring/prometheusrule.yaml
# Import Grafana dashboard from monitoring/grafana-dashboard.json
```
See [monitoring/README.md](monitoring/README.md) for complete observability setup including:
- Prometheus metrics and recording rules
- Alert rules for operational issues
- Grafana dashboard with key performance indicators
## Documentation
- [CLAUDE.md](CLAUDE.md) - Project specification and requirements
- [examples/](examples/) - Working examples and testing scenarios
- [monitoring/](monitoring/) - Prometheus, Grafana, and alerting setup
- [Helm Chart](charts/kubemirror/) - Kubernetes deployment via Helm
- [Project Repository](https://github.com/lukaszraczylo/kubemirror)
## License
See LICENSE file.
+84
View File
@@ -0,0 +1,84 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kubemirror-controller
namespace: kubemirror-system
labels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: controller
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: controller
template:
metadata:
labels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: controller
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
spec:
serviceAccountName: kubemirror-controller
securityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: controller
image: ghcr.io/lukaszraczylo/kubemirror:latest
imagePullPolicy: IfNotPresent
command:
- /kubemirror
args:
- --leader-elect
- --metrics-bind-address=:8080
- --health-probe-bind-address=:8081
- --max-targets=100
- --worker-threads=5
- --rate-limit-qps=50.0
- --rate-limit-burst=100
ports:
- name: metrics
containerPort: 8080
protocol: TCP
- name: health
containerPort: 8081
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: health
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: health
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
terminationGracePeriodSeconds: 10
+19
View File
@@ -0,0 +1,19 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: kubemirror-system
commonLabels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/managed-by: kustomize
resources:
- namespace.yaml
- rbac.yaml
- deployment.yaml
- service.yaml
images:
- name: ghcr.io/lukaszraczylo/kubemirror
newTag: latest
+8
View File
@@ -0,0 +1,8 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: kubemirror-system
labels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: system
+86
View File
@@ -0,0 +1,86 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: kubemirror-controller
namespace: kubemirror-system
labels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: controller
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kubemirror-controller
labels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: rbac
rules:
# Discovery - read access to all API groups for resource discovery
# This is required for auto-discovering available resource types
- apiGroups: ["*"]
resources: ["*"]
verbs:
- get
- list
- watch
# Full access to all mirrorable resources
# Required for creating, updating, and deleting mirrors across all resource types
# The controller will only mirror resources that are explicitly marked with
# kubemirror.raczylo.com/enabled label and kubemirror.raczylo.com/sync annotation
- apiGroups: ["*"]
resources: ["*"]
verbs:
- create
- update
- patch
- delete
# Namespaces - read only (for listing and filtering)
- apiGroups: [""]
resources:
- namespaces
verbs:
- get
- list
- watch
# Leader election - coordination.k8s.io/v1
- apiGroups: ["coordination.k8s.io"]
resources:
- leases
verbs:
- get
- list
- watch
- create
- update
- patch
- delete
# Events - for creating events about mirroring operations
- apiGroups: [""]
resources:
- events
verbs:
- create
- patch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: kubemirror-controller
labels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: rbac
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kubemirror-controller
subjects:
- kind: ServiceAccount
name: kubemirror-controller
namespace: kubemirror-system
+23
View File
@@ -0,0 +1,23 @@
---
apiVersion: v1
kind: Service
metadata:
name: kubemirror-controller-metrics
namespace: kubemirror-system
labels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: controller
spec:
type: ClusterIP
ports:
- name: metrics
port: 8080
targetPort: metrics
protocol: TCP
- name: health
port: 8081
targetPort: health
protocol: TCP
selector:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: controller
+250
View File
@@ -0,0 +1,250 @@
# KubeMirror Examples
This directory contains example manifests for testing KubeMirror functionality.
## Overview
The examples create 5 namespaces with various resources to demonstrate different mirroring scenarios:
### Namespace Structure
- **namespace-1**: Source namespace containing:
- `shared-credentials` Secret → mirrors to ALL namespaces
- `database-credentials` Secret → mirrors to namespace-3 and namespace-4
- `local-secret` Secret → NO mirroring (stays local)
- `app-config` ConfigMap → mirrors to ALL namespaces
- `nginx-config` ConfigMap → mirrors to namespace-2 and namespace-5
- **namespace-2**: Traefik middleware source namespace containing:
- `compression` Middleware → mirrors to namespace-4 and namespace-5
- `rate-limit` Middleware → mirrors to ALL namespaces
- `headers` Middleware → mirrors to namespace-3 only
- **namespace-3**: Target namespace (receives mirrors)
- **namespace-4**: Target namespace (receives mirrors + Traefik middleware)
- **namespace-5**: Target namespace (receives mirrors + Traefik middleware)
## Prerequisites
1. KubeMirror controller must be deployed and running
2. Traefik CRDs must be installed (for middleware examples)
```bash
# Install official Traefik CRDs (latest)
kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/master/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
```
**Note:** If you don't want to test Traefik middleware mirroring, you can skip the CRD installation and just exclude `traefik-middleware.yaml` from your apply command.
## Quick Start
Apply all examples using kustomize:
```bash
# Apply all examples
kubectl apply -k examples/
# Or apply individually
kubectl apply -f examples/namespaces.yaml
kubectl apply -f examples/source-secret.yaml
kubectl apply -f examples/source-configmap.yaml
kubectl apply -f examples/traefik-middleware.yaml
```
## Verification
### Check Namespaces
```bash
# List all example namespaces
kubectl get namespaces -l app=kubemirror-example
# Verify allow-mirrors label
kubectl get namespaces -l kubemirror.raczylo.com/allow-mirrors=true
```
### Check Mirrored Secrets
```bash
# Check shared-credentials (should exist in all namespaces)
kubectl get secret shared-credentials -n namespace-1
kubectl get secret shared-credentials -n namespace-2
kubectl get secret shared-credentials -n namespace-3
kubectl get secret shared-credentials -n namespace-4
kubectl get secret shared-credentials -n namespace-5
# Check database-credentials (only in namespace-3 and namespace-4)
kubectl get secret database-credentials -n namespace-3
kubectl get secret database-credentials -n namespace-4
# Check local-secret (should ONLY exist in namespace-1)
kubectl get secret local-secret -n namespace-1
kubectl get secret local-secret -n namespace-2 # Should NOT exist
```
### Check Mirrored ConfigMaps
```bash
# Check app-config (should exist in all namespaces)
kubectl get configmap app-config --all-namespaces
# Check nginx-config (only in namespace-2 and namespace-5)
kubectl get configmap nginx-config -n namespace-2
kubectl get configmap nginx-config -n namespace-5
```
### Check Mirrored Traefik Middlewares
```bash
# Check compression middleware (should be in namespace-4 and namespace-5)
kubectl get middleware compression -n namespace-2
kubectl get middleware compression -n namespace-4
kubectl get middleware compression -n namespace-5
# Check rate-limit middleware (should be in all namespaces)
kubectl get middleware rate-limit --all-namespaces
# Check headers middleware (should be in namespace-3)
kubectl get middleware headers -n namespace-3
```
### Check Mirror Ownership
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
# Should include:
# kubemirror.raczylo.com/mirrored: "true"
# kubemirror.raczylo.com/source-namespace: namespace-1
# kubemirror.raczylo.com/source-name: shared-credentials
```
## Testing Update Propagation
Test that updates to source resources propagate to mirrors:
```bash
# Update the shared-credentials secret
kubectl patch secret shared-credentials -n namespace-1 \
--type='json' \
-p='[{"op": "replace", "path": "/data/password", "value": "'$(echo -n "new-password" | base64)'"}]'
# Wait a few seconds, then verify the change propagated
kubectl get secret shared-credentials -n namespace-3 -o jsonpath='{.data.password}' | base64 -d
# Should output: new-password
```
## Testing Deletion Behavior
Test that deleting source resources deletes mirrors:
```bash
# Delete a source secret
kubectl delete secret database-credentials -n namespace-1
# Wait a few seconds, verify mirrors are also deleted
kubectl get secret database-credentials -n namespace-3 # Should not exist
kubectl get secret database-credentials -n namespace-4 # Should not exist
```
Test that deleting a mirror recreates it (if source still exists):
```bash
# Delete a mirrored resource
kubectl delete secret shared-credentials -n namespace-4
# Wait a few seconds, verify it's recreated
kubectl get secret shared-credentials -n namespace-4 # Should exist again
```
## Cleanup
Remove all examples:
```bash
# Delete all resources
kubectl delete -k examples/
# Or delete individually
kubectl delete -f examples/traefik-middleware.yaml
kubectl delete -f examples/source-configmap.yaml
kubectl delete -f examples/source-secret.yaml
kubectl delete -f examples/namespaces.yaml
```
## Troubleshooting
### View KubeMirror Logs
```bash
# View controller logs
kubectl logs -n kubemirror-system -l app.kubernetes.io/name=kubemirror -f
```
### Check Controller Events
```bash
# View events in a specific namespace
kubectl get events -n namespace-3 --sort-by='.lastTimestamp'
# Look for mirror-related events
kubectl get events --all-namespaces | grep -i mirror
```
### Verify Controller is Running
```bash
# Check controller deployment
kubectl get deployment -n kubemirror-system
# Check controller pods
kubectl get pods -n kubemirror-system
```
### Common Issues
1. **Mirrors not created**: Ensure target namespaces have the `kubemirror.raczylo.com/allow-mirrors: "true"` label
2. **Updates not propagating**: Check controller logs for errors or rate limiting
3. **Traefik resources not mirroring**: Ensure Traefik CRDs are installed in the cluster
4. **Permission errors**: Verify the controller has proper RBAC permissions
## 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
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: namespace-1
annotations:
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "all"
kubemirror.raczylo.com/namespace-pattern: "app-.*"
labels:
kubemirror.raczylo.com/enabled: "true"
data:
config: "value"
```
+22
View File
@@ -0,0 +1,22 @@
---
# Example: Mirror a ConfigMap to all namespaces (except excluded ones)
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
# Mirror to all namespaces (except kube-system, kube-public, etc.)
kubemirror.raczylo.com/target-namespaces: "all"
data:
app.conf: |
server {
listen 8080;
location / {
proxy_pass http://backend:3000;
}
}
log-level: "info"
+13
View File
@@ -0,0 +1,13 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespaces.yaml
- https://raw.githubusercontent.com/traefik/traefik/master/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
- source-secret.yaml
- source-configmap.yaml
- traefik-middleware.yaml
commonLabels:
managed-by: kustomize
example: kubemirror
+49
View File
@@ -0,0 +1,49 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: namespace-1
labels:
# This namespace contains source resources
kubemirror.raczylo.com/allow-mirrors: "true"
app: kubemirror-example
---
apiVersion: v1
kind: Namespace
metadata:
name: namespace-2
labels:
# This namespace contains Traefik middleware
kubemirror.raczylo.com/allow-mirrors: "true"
app: kubemirror-example
---
apiVersion: v1
kind: Namespace
metadata:
name: namespace-3
labels:
# This namespace will receive mirrored resources
kubemirror.raczylo.com/allow-mirrors: "true"
app: kubemirror-example
---
apiVersion: v1
kind: Namespace
metadata:
name: namespace-4
labels:
# This namespace will receive all mirrors + Traefik middleware
kubemirror.raczylo.com/allow-mirrors: "true"
app: kubemirror-example
---
apiVersion: v1
kind: Namespace
metadata:
name: namespace-5
labels:
# This namespace will receive all mirrors + Traefik middleware
kubemirror.raczylo.com/allow-mirrors: "true"
app: kubemirror-example
+19
View File
@@ -0,0 +1,19 @@
---
# Example: Mirror a secret to specific namespaces
apiVersion: v1
kind: Secret
metadata:
name: my-app-secret
namespace: default
labels:
# REQUIRED: Enable mirroring with label (for server-side filtering)
kubemirror.raczylo.com/enabled: "true"
annotations:
# REQUIRED: Sync annotation
kubemirror.raczylo.com/sync: "true"
# REQUIRED: Target namespaces (comma-separated)
kubemirror.raczylo.com/target-namespaces: "app1,app2,app3"
type: Opaque
data:
username: YWRtaW4= # admin
password: cGFzc3dvcmQxMjM= # password123
+17
View File
@@ -0,0 +1,17 @@
---
# Example: Mirror a secret to all namespaces matching a pattern
apiVersion: v1
kind: Secret
metadata:
name: tls-cert
namespace: default
labels:
kubemirror.raczylo.com/enabled: "true"
annotations:
kubemirror.raczylo.com/sync: "true"
# Mirror to all namespaces starting with "app-"
kubemirror.raczylo.com/target-namespaces: "app-*"
type: kubernetes.io/tls
data:
tls.crt: LS0tLS1CRUdJTi... # Base64 encoded cert
tls.key: LS0tLS1CRUdJTi... # Base64 encoded key
+79
View File
@@ -0,0 +1,79 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: namespace-1
annotations:
# Mirror this ConfigMap to all namespaces
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "all"
labels:
# Required: enables server-side filtering
kubemirror.raczylo.com/enabled: "true"
app: kubemirror-example
resource-type: shared-config
data:
app.properties: |
server.port=8080
server.host=0.0.0.0
log.level=info
feature.enabled=true
database.yaml: |
database:
pool:
min: 5
max: 20
timeout: 30s
settings.json: |
{
"theme": "dark",
"language": "en",
"timezone": "UTC",
"features": {
"beta": false,
"experimental": false
}
}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
namespace: namespace-1
annotations:
# Mirror to specific namespaces only
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "namespace-2,namespace-5"
labels:
# Required: enables server-side filtering
kubemirror.raczylo.com/enabled: "true"
app: kubemirror-example
resource-type: nginx-config
data:
nginx.conf: |
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
gzip on;
}
+56
View File
@@ -0,0 +1,56 @@
---
apiVersion: v1
kind: Secret
metadata:
name: shared-credentials
namespace: namespace-1
annotations:
# Mirror this secret to all namespaces with allow-mirrors label
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "all"
labels:
# Required: enables server-side filtering
kubemirror.raczylo.com/enabled: "true"
app: kubemirror-example
resource-type: shared-secret
type: Opaque
stringData:
username: admin
password: super-secret-password
api-key: "1234567890abcdef"
---
apiVersion: v1
kind: Secret
metadata:
name: database-credentials
namespace: namespace-1
annotations:
# Mirror this secret only to specific namespaces
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "namespace-3,namespace-4"
labels:
# Required: enables server-side filtering
kubemirror.raczylo.com/enabled: "true"
app: kubemirror-example
resource-type: database-secret
type: Opaque
stringData:
db-host: "postgres.example.com"
db-user: "appuser"
db-password: "db-secret-password"
db-name: "myapp"
---
apiVersion: v1
kind: Secret
metadata:
name: local-secret
namespace: namespace-1
labels:
app: kubemirror-example
resource-type: local-only
type: Opaque
stringData:
# This secret has NO mirror annotation, so it stays local
local-data: "This stays in namespace-1 only"
+64
View File
@@ -0,0 +1,64 @@
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: compression
namespace: namespace-2
annotations:
# Mirror this Traefik middleware to namespace-4 and namespace-5
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "namespace-4,namespace-5"
labels:
# Required: enables server-side filtering
kubemirror.raczylo.com/enabled: "true"
app: kubemirror-example
resource-type: traefik-middleware
spec:
compress:
excludedContentTypes:
- text/event-stream
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: rate-limit
namespace: namespace-2
annotations:
# Mirror to all namespaces for consistent rate limiting
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "all"
labels:
# Required: enables server-side filtering
kubemirror.raczylo.com/enabled: "true"
app: kubemirror-example
resource-type: traefik-middleware
spec:
rateLimit:
average: 100
burst: 50
period: 1m
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: headers
namespace: namespace-2
annotations:
# Mirror only to namespace-3
kubemirror.raczylo.com/sync: "true"
kubemirror.raczylo.com/target-namespaces: "namespace-3"
labels:
# Required: enables server-side filtering
kubemirror.raczylo.com/enabled: "true"
app: kubemirror-example
resource-type: traefik-middleware
spec:
headers:
customRequestHeaders:
X-Forwarded-Proto: "https"
X-Frame-Options: "DENY"
X-Content-Type-Options: "nosniff"
customResponseHeaders:
X-Custom-Response-Header: "kubemirror-example"
+70
View File
@@ -0,0 +1,70 @@
module github.com/lukaszraczylo/kubemirror
go 1.25.5
require (
github.com/stretchr/testify v1.11.1
k8s.io/api v0.31.4
k8s.io/apimachinery v0.31.4
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.19.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.3.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.31.0 // indirect
k8s.io/client-go v0.31.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 // indirect
sigs.k8s.io/controller-runtime v0.19.4 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
+181
View File
@@ -0,0 +1,181 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/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.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
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=
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.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.31.4 h1:I2QNzitPVsPeLQvexMEsj945QumYraqv9m74isPDKhM=
k8s.io/api v0.31.4/go.mod h1:d+7vgXLvmcdT1BCo79VEgJxHHryww3V5np2OYTr6jdw=
k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk=
k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk=
k8s.io/apimachinery v0.31.4 h1:8xjE2C4CzhYVm9DGf60yohpNUh5AEBnPxCryPBECmlM=
k8s.io/apimachinery v0.31.4/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8=
k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2 h1:OfgiEo21hGiwx1oJUU5MpEaeOEg6coWndBkZF/lkFuE=
k8s.io/utils v0.0.0-20251222233032-718f0e51e6d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/controller-runtime v0.19.4 h1:SUmheabttt0nx8uJtoII4oIP27BVVvAKFvdvGFwV/Qo=
sigs.k8s.io/controller-runtime v0.19.4/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4=
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/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
+267
View File
@@ -0,0 +1,267 @@
# KubeMirror Monitoring
This directory contains observability resources for monitoring KubeMirror in production.
## Overview
KubeMirror exposes Prometheus metrics on port 8080 at `/metrics`. The monitoring stack includes:
- **ServiceMonitor**: Prometheus Operator resource for automatic metric scraping
- **PrometheusRule**: Alert rules for common operational issues
- **Grafana Dashboard**: Comprehensive visualization of controller metrics
## Prerequisites
- Prometheus Operator installed in your cluster
- Grafana (optional, for dashboards)
```bash
# Install Prometheus Operator (if not already installed)
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install prometheus prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--create-namespace
```
## Quick Start
### Deploy Monitoring Resources
```bash
# Apply ServiceMonitor and PrometheusRule
kubectl apply -f monitoring/servicemonitor.yaml
kubectl apply -f monitoring/prometheusrule.yaml
```
### Import Grafana Dashboard
1. **Via UI:**
- Open Grafana
- Go to Dashboards → Import
- Upload `grafana-dashboard.json`
- Select your Prometheus datasource
2. **Via ConfigMap (GitOps):**
```bash
kubectl create configmap kubemirror-dashboard \
--from-file=dashboard.json=monitoring/grafana-dashboard.json \
-n monitoring \
--dry-run=client -o yaml | kubectl apply -f -
# Label for automatic discovery by Grafana
kubectl label configmap kubemirror-dashboard \
grafana_dashboard=1 \
-n monitoring
```
## Available Metrics
### Controller Runtime Metrics
These metrics are provided by the controller-runtime framework:
- `controller_runtime_reconcile_total` - Total reconciliations (by controller, result)
- `controller_runtime_reconcile_errors_total` - Failed reconciliations
- `controller_runtime_reconcile_time_seconds` - Reconciliation duration histogram
- `workqueue_depth` - Current workqueue depth
- `workqueue_adds_total` - Total items added to workqueue
- `workqueue_retries_total` - Workqueue retry count
### Leader Election Metrics
- `leader_election_master_status` - Leader election status (1 = leader, 0 = follower)
### Go Runtime Metrics
- `go_goroutines` - Current goroutine count
- `go_memstats_alloc_bytes` - Allocated memory
- `process_open_fds` - Open file descriptors
- `process_cpu_seconds_total` - CPU time
## Alert Rules
The PrometheusRule defines alerts for:
### Critical Alerts
- **KubeMirrorControllerDown**: Controller pod is not running
- Severity: `critical`
- Fires after: 5 minutes
### Warning Alerts
- **KubeMirrorHighReconcileErrors**: High error rate in reconciliation
- Threshold: >10% error rate
- Fires after: 10 minutes
- **KubeMirrorReconcileLatencyHigh**: Slow reconciliation loops
- Threshold: p99 latency > 5 seconds
- Fires after: 10 minutes
- **KubeMirrorWorkqueueDepthHigh**: Work items piling up
- Threshold: >100 items in queue
- Fires after: 15 minutes
- **KubeMirrorLeaderElectionLost**: Controller is not the leader
- Fires after: 2 minutes
- **KubeMirrorHighFailureRate**: Overall operation failure rate high
- Threshold: >5% failure rate
- Fires after: 10 minutes
- **KubeMirrorMemoryHigh**: High memory usage
- Threshold: >90% of memory limit
- Fires after: 5 minutes
- **KubeMirrorCPUThrottling**: CPU throttling detected
- Fires after: 10 minutes
## Recording Rules
Recording rules pre-compute expensive queries for better dashboard performance:
- `kubemirror:reconcile_duration_seconds:p99` - P99 reconciliation latency
- `kubemirror:reconcile_duration_seconds:p95` - P95 reconciliation latency
- `kubemirror:reconcile_duration_seconds:p50` - P50 reconciliation latency
- `kubemirror:reconcile_rate:5m` - Reconciliation rate (5m window)
- `kubemirror:reconcile_errors:rate5m` - Error rate (5m window)
- `kubemirror:workqueue_depth:max` - Max workqueue depth
## Grafana Dashboard
The dashboard includes the following panels:
1. **Controller Status** - Up/down status
2. **Reconciliation Rate** - Operations per second by type and result
3. **Total Workqueue Depth** - Combined queue depth across controllers
4. **Reconciliation Latency** - P99 and P95 latency trends
5. **Workqueue Depth** - Per-controller queue depth
6. **Memory Usage** - Working set vs limits
7. **CPU Usage** - CPU utilization percentage
8. **Error Rate** - Percentage of failed reconciliations
9. **Process Stats** - Goroutines and file descriptors
## Querying Metrics
### Using Prometheus UI
```promql
# Total reconciliation rate
sum(rate(controller_runtime_reconcile_total[5m])) by (controller, result)
# Error rate
sum(rate(controller_runtime_reconcile_errors_total[5m])) by (controller)
# P99 latency
histogram_quantile(0.99,
sum(rate(controller_runtime_reconcile_time_seconds_bucket[5m])) by (le, controller)
)
# Current workqueue depth
workqueue_depth{name=~"secret|configmap"}
```
### Using kubectl
```bash
# Port-forward to metrics endpoint
kubectl port-forward -n kubemirror-system svc/kubemirror-controller-metrics 8080:8080
# Curl metrics (raw Prometheus format)
curl http://localhost:8080/metrics
```
## Troubleshooting
### ServiceMonitor Not Scraping
Check if Prometheus Operator is configured to discover ServiceMonitors in the kubemirror-system namespace:
```bash
# Check ServiceMonitor status
kubectl get servicemonitor -n kubemirror-system
# Check Prometheus targets
kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090
# Open http://localhost:9090/targets
```
### Alerts Not Firing
Verify PrometheusRule is loaded:
```bash
# Check PrometheusRule
kubectl get prometheusrule -n kubemirror-system
# Check Prometheus rules
kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090
# Open http://localhost:9090/rules
```
### High Memory Usage
If alerts fire for high memory:
1. Check for memory leaks in controller logs
2. Increase memory limits in Helm values:
```yaml
resources:
limits:
memory: 1Gi
```
3. Reduce worker threads or max targets if necessary
### High Reconciliation Latency
If reconciliation is slow:
1. Check API server latency: `kubectl get --raw /metrics | grep apiserver_request_duration`
2. Increase worker threads in Helm values:
```yaml
controller:
workerThreads: 10
```
3. Review rate limiting settings if hitting API limits
## Integration with Alertmanager
To route KubeMirror alerts to specific channels:
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: alertmanager-config
namespace: monitoring
data:
alertmanager.yml: |
route:
routes:
- match:
component: kubemirror
receiver: kubemirror-team
continue: true
receivers:
- name: kubemirror-team
slack_configs:
- channel: '#kubemirror-alerts'
api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
```
## Best Practices
1. **Set up alerts** - Deploy PrometheusRule to catch issues early
2. **Monitor trends** - Use Grafana dashboard to spot degradation over time
3. **Baseline metrics** - Understand normal behavior during low/high load
4. **Tune resources** - Adjust CPU/memory based on actual usage patterns
5. **Alert fatigue** - Tune alert thresholds to reduce false positives
6. **Retention** - Ensure Prometheus retains metrics for at least 7 days
## Further Reading
- [Prometheus Operator Documentation](https://prometheus-operator.dev/)
- [Grafana Dashboard Best Practices](https://grafana.com/docs/grafana/latest/best-practices/best-practices-for-creating-dashboards/)
- [Controller Runtime Metrics](https://book.kubebuilder.io/reference/metrics.html)
+678
View File
@@ -0,0 +1,678 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "KubeMirror Controller Metrics Dashboard",
"editable": true,
"gnetId": null,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
},
"unit": "short"
}
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.0.0",
"targets": [
{
"expr": "up{service=\"kubemirror-controller-metrics\"}",
"refId": "A"
}
],
"title": "Controller Status",
"type": "stat"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "ops"
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 6,
"y": 0
},
"id": 2,
"options": {
"legend": {
"calcs": ["mean", "last"],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi"
}
},
"pluginVersion": "8.0.0",
"targets": [
{
"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)",
"legendFormat": "ConfigMap - {{result}}",
"refId": "B"
}
],
"title": "Reconciliation Rate",
"type": "timeseries"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "yellow",
"value": 5
},
{
"color": "red",
"value": 10
}
]
},
"unit": "short"
}
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 0
},
"id": 3,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"text": {},
"textMode": "auto"
},
"pluginVersion": "8.0.0",
"targets": [
{
"expr": "sum(workqueue_depth{name=~\"secret|configmap\"})",
"refId": "A"
}
],
"title": "Total Workqueue Depth",
"type": "stat"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "s"
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 4
},
"id": 4,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi"
}
},
"pluginVersion": "8.0.0",
"targets": [
{
"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))",
"legendFormat": "Secret p95",
"refId": "B"
},
{
"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))",
"legendFormat": "ConfigMap p95",
"refId": "D"
}
],
"title": "Reconciliation Latency",
"type": "timeseries"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 4
},
"id": 5,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi"
}
},
"pluginVersion": "8.0.0",
"targets": [
{
"expr": "workqueue_depth{name=\"secret\"}",
"legendFormat": "Secret",
"refId": "A"
},
{
"expr": "workqueue_depth{name=\"configmap\"}",
"legendFormat": "ConfigMap",
"refId": "B"
}
],
"title": "Workqueue Depth",
"type": "timeseries"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "bytes"
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 12
},
"id": 6,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi"
}
},
"pluginVersion": "8.0.0",
"targets": [
{
"expr": "container_memory_working_set_bytes{pod=~\"kubemirror-.*\",container=\"controller\"}",
"legendFormat": "Memory Usage",
"refId": "A"
},
{
"expr": "container_spec_memory_limit_bytes{pod=~\"kubemirror-.*\",container=\"controller\"}",
"legendFormat": "Memory Limit",
"refId": "B"
}
],
"title": "Memory Usage",
"type": "timeseries"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "percent"
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 12
},
"id": 7,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi"
}
},
"pluginVersion": "8.0.0",
"targets": [
{
"expr": "rate(container_cpu_usage_seconds_total{pod=~\"kubemirror-.*\",container=\"controller\"}[5m]) * 100",
"legendFormat": "CPU Usage",
"refId": "A"
}
],
"title": "CPU Usage",
"type": "timeseries"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 0.05
}
]
},
"unit": "percentunit"
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 20
},
"id": 8,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi"
}
},
"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]))",
"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]))",
"legendFormat": "ConfigMap Error Rate",
"refId": "B"
}
],
"title": "Error Rate",
"type": "timeseries"
},
{
"datasource": "Prometheus",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": true
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "short"
}
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 20
},
"id": 9,
"options": {
"legend": {
"calcs": ["mean", "last"],
"displayMode": "table",
"placement": "right"
},
"tooltip": {
"mode": "multi"
}
},
"pluginVersion": "8.0.0",
"targets": [
{
"expr": "process_open_fds{job=\"kubemirror-controller-metrics\"}",
"legendFormat": "Open File Descriptors",
"refId": "A"
},
{
"expr": "go_goroutines{job=\"kubemirror-controller-metrics\"}",
"legendFormat": "Goroutines",
"refId": "B"
}
],
"title": "Process Stats",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 27,
"style": "dark",
"tags": ["kubernetes", "kubemirror", "controller"],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "KubeMirror Controller",
"uid": "kubemirror-controller",
"version": 1
}
+146
View File
@@ -0,0 +1,146 @@
---
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: kubemirror-alerts
namespace: kubemirror-system
labels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: monitoring
spec:
groups:
- name: kubemirror.rules
interval: 30s
rules:
# Controller health alerts
- alert: KubeMirrorControllerDown
expr: up{service="kubemirror-controller-metrics"} == 0
for: 5m
labels:
severity: critical
component: kubemirror
annotations:
summary: "KubeMirror controller is down"
description: "KubeMirror controller in namespace {{ $labels.namespace }} has been down for more than 5 minutes."
- alert: KubeMirrorHighReconcileErrors
expr: |
rate(controller_runtime_reconcile_errors_total{controller="secret"}[5m]) > 0.1
or
rate(controller_runtime_reconcile_errors_total{controller="configmap"}[5m]) > 0.1
for: 10m
labels:
severity: warning
component: kubemirror
annotations:
summary: "High reconciliation error rate in KubeMirror"
description: "KubeMirror controller {{ $labels.controller }} is experiencing high error rate: {{ $value | humanizePercentage }} errors/sec"
- alert: KubeMirrorReconcileLatencyHigh
expr: |
histogram_quantile(0.99,
rate(controller_runtime_reconcile_time_seconds_bucket{controller=~"secret|configmap"}[5m])
) > 5
for: 10m
labels:
severity: warning
component: kubemirror
annotations:
summary: "High reconciliation latency in KubeMirror"
description: "KubeMirror {{ $labels.controller }} controller p99 latency is {{ $value | humanizeDuration }}"
- alert: KubeMirrorWorkqueueDepthHigh
expr: |
workqueue_depth{name=~"secret|configmap"} > 100
for: 15m
labels:
severity: warning
component: kubemirror
annotations:
summary: "High workqueue depth in KubeMirror"
description: "KubeMirror {{ $labels.name }} workqueue has {{ $value }} items pending for more than 15 minutes"
- alert: KubeMirrorLeaderElectionLost
expr: |
leader_election_master_status{name="kubemirror-controller-leader"} == 0
for: 2m
labels:
severity: warning
component: kubemirror
annotations:
summary: "KubeMirror lost leader election"
description: "KubeMirror controller on pod {{ $labels.pod }} is not the leader"
# Resource mirror alerts
- alert: KubeMirrorHighFailureRate
expr: |
sum(rate(controller_runtime_reconcile_errors_total{controller=~"secret|configmap"}[5m]))
/
sum(rate(controller_runtime_reconcile_total{controller=~"secret|configmap"}[5m]))
> 0.05
for: 10m
labels:
severity: warning
component: kubemirror
annotations:
summary: "High mirror operation failure rate"
description: "KubeMirror has {{ $value | humanizePercentage }} failure rate over the last 10 minutes"
- alert: KubeMirrorMemoryHigh
expr: |
container_memory_working_set_bytes{pod=~"kubemirror-.*",container="controller"}
/
container_spec_memory_limit_bytes{pod=~"kubemirror-.*",container="controller"}
> 0.9
for: 5m
labels:
severity: warning
component: kubemirror
annotations:
summary: "KubeMirror controller high memory usage"
description: "KubeMirror controller {{ $labels.pod }} is using {{ $value | humanizePercentage }} of its memory limit"
- alert: KubeMirrorCPUThrottling
expr: |
rate(container_cpu_cfs_throttled_seconds_total{pod=~"kubemirror-.*",container="controller"}[5m]) > 0.5
for: 10m
labels:
severity: warning
component: kubemirror
annotations:
summary: "KubeMirror controller is being CPU throttled"
description: "KubeMirror controller {{ $labels.pod }} is experiencing CPU throttling: {{ $value | humanizeDuration }}/sec"
- name: kubemirror.recording
interval: 30s
rules:
# Recording rules for better query performance
- record: kubemirror:reconcile_duration_seconds:p99
expr: |
histogram_quantile(0.99,
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])
)
- record: kubemirror:reconcile_duration_seconds:p50
expr: |
histogram_quantile(0.50,
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)
- record: kubemirror:reconcile_errors:rate5m
expr: |
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)
+30
View File
@@ -0,0 +1,30 @@
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: kubemirror-controller
namespace: kubemirror-system
labels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: controller
spec:
selector:
matchLabels:
app.kubernetes.io/name: kubemirror
app.kubernetes.io/component: controller
endpoints:
- port: metrics
interval: 30s
path: /metrics
scheme: http
scrapeTimeout: 10s
relabelings:
# Add namespace label
- sourceLabels: [__meta_kubernetes_namespace]
targetLabel: namespace
# Add pod label
- sourceLabels: [__meta_kubernetes_pod_name]
targetLabel: pod
# Add service label
- sourceLabels: [__meta_kubernetes_service_name]
targetLabel: service
+77
View File
@@ -0,0 +1,77 @@
// Package config provides configuration for the kubemirror controller.
package config
import (
"time"
)
// Config holds all configuration for the controller.
type Config struct {
// MetricsBindAddress is the address for the metrics endpoint
MetricsBindAddress string
// HealthProbeBindAddress is the address for health probes
HealthProbeBindAddress string
// WatchNamespaces is the list of namespaces to watch (empty = all namespaces)
WatchNamespaces []string
// ExcludedNamespaces is the list of namespaces to never mirror to
ExcludedNamespaces []string
// MirroredResourceTypes is the list of resource types to mirror
// If empty, defaults to Secret and ConfigMap only
MirroredResourceTypes []ResourceType
// DeniedResourceTypes is the deny-list of resource types (by name, for backward compatibility)
DeniedResourceTypes []string
// LeaderElection configuration
LeaderElection LeaderElectionConfig
// ReconcileInterval is how often to re-check all resources
ReconcileInterval time.Duration
// WorkerThreads is the number of concurrent reconciliation workers
WorkerThreads int
// RateLimitBurst is the burst capacity for rate limiting
RateLimitBurst int
// MemoryLimitMB is the memory limit in megabytes
MemoryLimitMB int
// DebounceDuration is the debounce window for source updates
DebounceDuration time.Duration
// MaxTargetsPerResource is the maximum number of target namespaces per resource
MaxTargetsPerResource int
// RateLimitQPS is the maximum queries per second to the API server
RateLimitQPS float32
// RequireNamespaceOptIn requires namespaces to have label for "all" mirrors
RequireNamespaceOptIn bool
// EnableAllKeyword enables the "all" keyword for target namespaces
EnableAllKeyword bool
// DryRun mode logs what would happen without actually making changes
DryRun bool
}
// LeaderElectionConfig holds leader election settings.
type LeaderElectionConfig struct {
// ResourceName is the name of the leader election resource
ResourceName string
// ResourceNamespace is the namespace for the leader election resource
ResourceNamespace string
// LeaseDuration is the lease duration
LeaseDuration time.Duration
// RenewDeadline is the renew deadline
RenewDeadline time.Duration
// RetryPeriod is the retry period
RetryPeriod time.Duration
// Enabled enables leader election
Enabled bool
}
// Validate checks if the configuration is valid.
func (c *Config) Validate() error {
// Add validation logic if needed
return nil
}
+98
View File
@@ -0,0 +1,98 @@
package config
import (
"fmt"
"strings"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// ResourceType defines a Kubernetes resource type to mirror.
type ResourceType struct {
Group string
Version string
Kind string
}
// GroupVersionKind returns the GVK for this resource type.
func (r ResourceType) GroupVersionKind() schema.GroupVersionKind {
return schema.GroupVersionKind{
Group: r.Group,
Version: r.Version,
Kind: r.Kind,
}
}
// String returns a string representation of the resource type.
func (r ResourceType) String() string {
if r.Group == "" {
return fmt.Sprintf("%s.%s", r.Kind, r.Version)
}
return fmt.Sprintf("%s.%s.%s", r.Kind, r.Version, r.Group)
}
// ParseResourceType parses a resource type string in the format "kind.version.group" or "kind.version".
// Examples: "Secret.v1", "Ingress.v1.networking.k8s.io", "Middleware.v1alpha1.traefik.io"
func ParseResourceType(s string) (ResourceType, error) {
parts := strings.Split(s, ".")
switch len(parts) {
case 2:
// Core resources: "Secret.v1"
return ResourceType{
Kind: parts[0],
Version: parts[1],
Group: "",
}, nil
case 3:
// Resources with group: "Ingress.v1.networking.k8s.io"
return ResourceType{
Kind: parts[0],
Version: parts[1],
Group: parts[2],
}, nil
default:
// Support more complex groups with dots: "Middleware.v1alpha1.traefik.io"
if len(parts) >= 3 {
return ResourceType{
Kind: parts[0],
Version: parts[1],
Group: strings.Join(parts[2:], "."),
}, nil
}
return ResourceType{}, fmt.Errorf("invalid resource type format: %s (expected kind.version or kind.version.group)", s)
}
}
// DefaultResourceTypes returns the default set of resource types to mirror.
func DefaultResourceTypes() []ResourceType {
return []ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
}
}
// ParseResourceTypes parses a comma-separated list of resource type strings.
func ParseResourceTypes(s string) ([]ResourceType, error) {
if s == "" {
return DefaultResourceTypes(), nil
}
parts := strings.Split(s, ",")
types := make([]ResourceType, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
rt, err := ParseResourceType(part)
if err != nil {
return nil, fmt.Errorf("failed to parse resource type %q: %w", part, err)
}
types = append(types, rt)
}
return types, nil
}
+225
View File
@@ -0,0 +1,225 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func TestParseResourceType(t *testing.T) {
tests := []struct {
name string
input string
want ResourceType
wantErr bool
}{
{
name: "core resource - Secret",
input: "Secret.v1",
want: ResourceType{
Kind: "Secret",
Version: "v1",
Group: "",
},
},
{
name: "core resource - ConfigMap",
input: "ConfigMap.v1",
want: ResourceType{
Kind: "ConfigMap",
Version: "v1",
Group: "",
},
},
{
name: "resource with simple group",
input: "Ingress.v1.networking.k8s.io",
want: ResourceType{
Kind: "Ingress",
Version: "v1",
Group: "networking.k8s.io",
},
},
{
name: "resource with complex group",
input: "Middleware.v1alpha1.traefik.io",
want: ResourceType{
Kind: "Middleware",
Version: "v1alpha1",
Group: "traefik.io",
},
},
{
name: "CRD example",
input: "Certificate.v1.cert-manager.io",
want: ResourceType{
Kind: "Certificate",
Version: "v1",
Group: "cert-manager.io",
},
},
{
name: "invalid format - single part",
input: "Secret",
wantErr: true,
},
{
name: "empty string",
input: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseResourceType(tt.input)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestResourceType_GroupVersionKind(t *testing.T) {
tests := []struct {
name string
rt ResourceType
want schema.GroupVersionKind
}{
{
name: "core resource",
rt: ResourceType{
Kind: "Secret",
Version: "v1",
Group: "",
},
want: schema.GroupVersionKind{
Kind: "Secret",
Version: "v1",
Group: "",
},
},
{
name: "resource with group",
rt: ResourceType{
Kind: "Ingress",
Version: "v1",
Group: "networking.k8s.io",
},
want: schema.GroupVersionKind{
Kind: "Ingress",
Version: "v1",
Group: "networking.k8s.io",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.rt.GroupVersionKind()
assert.Equal(t, tt.want, got)
})
}
}
func TestResourceType_String(t *testing.T) {
tests := []struct {
name string
rt ResourceType
want string
}{
{
name: "core resource",
rt: ResourceType{
Kind: "Secret",
Version: "v1",
Group: "",
},
want: "Secret.v1",
},
{
name: "resource with group",
rt: ResourceType{
Kind: "Ingress",
Version: "v1",
Group: "networking.k8s.io",
},
want: "Ingress.v1.networking.k8s.io",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.rt.String()
assert.Equal(t, tt.want, got)
})
}
}
func TestParseResourceTypes(t *testing.T) {
tests := []struct {
name string
input string
want []ResourceType
wantErr bool
}{
{
name: "empty string returns defaults",
input: "",
want: DefaultResourceTypes(),
},
{
name: "single resource type",
input: "Secret.v1",
want: []ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
},
},
{
name: "multiple resource types",
input: "Secret.v1,ConfigMap.v1,Ingress.v1.networking.k8s.io",
want: []ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
},
},
{
name: "with whitespace",
input: " Secret.v1 , ConfigMap.v1 ",
want: []ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
},
{
name: "invalid format in list",
input: "Secret.v1,Invalid",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseResourceTypes(tt.input)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestDefaultResourceTypes(t *testing.T) {
defaults := DefaultResourceTypes()
assert.Len(t, defaults, 2)
assert.Contains(t, defaults, ResourceType{Kind: "Secret", Version: "v1", Group: ""})
assert.Contains(t, defaults, ResourceType{Kind: "ConfigMap", Version: "v1", Group: ""})
}
+132
View File
@@ -0,0 +1,132 @@
// Package constants defines all annotation keys, label keys, and constant values
// used by the kubemirror controller.
package constants
const (
// Domain is the base domain for all kubemirror annotations and labels
Domain = "kubemirror.raczylo.com"
// Labels
// LabelEnabled is the label used for server-side filtering in watches.
// Resources must have this label set to "true" to be processed by the controller.
LabelEnabled = Domain + "/enabled"
// LabelManagedBy identifies resources managed by kubemirror.
LabelManagedBy = Domain + "/managed-by"
// LabelMirror marks a resource as a mirror (target resource).
LabelMirror = Domain + "/mirror"
// LabelAllowMirrors is set on namespaces to opt-in for "all" mirrors.
LabelAllowMirrors = Domain + "/allow-mirrors"
// Annotations
// AnnotationSync marks a resource for mirroring when set to "true".
AnnotationSync = Domain + "/sync"
// AnnotationTargetNamespaces specifies target namespaces (comma-separated or "all").
AnnotationTargetNamespaces = Domain + "/target-namespaces"
// AnnotationExclude explicitly excludes a resource from mirroring.
AnnotationExclude = Domain + "/exclude"
// AnnotationMaxTargets overrides the default maximum target limit per resource.
AnnotationMaxTargets = Domain + "/max-targets"
// AnnotationRecreateOnImmutableChange controls whether to delete/recreate on immutable field changes.
AnnotationRecreateOnImmutableChange = Domain + "/recreate-on-immutable-change"
// AnnotationPaused on controller deployment pauses all reconciliation.
AnnotationPaused = Domain + "/paused"
// Source Resource Annotations (tracking)
// AnnotationContentHash stores the SHA256 hash of the source resource content.
AnnotationContentHash = Domain + "/content-hash"
// Target Resource Annotations (ownership and tracking)
// AnnotationSourceNamespace stores the namespace of the source resource.
AnnotationSourceNamespace = Domain + "/source-namespace"
// AnnotationSourceName stores the name of the source resource.
AnnotationSourceName = Domain + "/source-name"
// AnnotationSourceUID stores the UID of the source resource.
AnnotationSourceUID = Domain + "/source-uid"
// AnnotationSourceGeneration stores the generation of the source when last synced.
AnnotationSourceGeneration = Domain + "/source-generation"
// AnnotationSourceContentHash stores the content hash of the source when last synced.
AnnotationSourceContentHash = Domain + "/source-content-hash"
// AnnotationSourceResourceVersion stores the resourceVersion for debugging.
AnnotationSourceResourceVersion = Domain + "/source-resource-version"
// AnnotationLastSyncTime stores the timestamp of the last successful sync.
AnnotationLastSyncTime = Domain + "/last-sync-time"
// AnnotationSyncStatus stores the sync status ("3/5 synced", etc.).
AnnotationSyncStatus = Domain + "/sync-status"
// AnnotationFailedTargets stores comma-separated list of failed target namespaces.
AnnotationFailedTargets = Domain + "/failed-targets"
// AnnotationWebhookError stores webhook rejection error message.
AnnotationWebhookError = Domain + "/webhook-error"
// AnnotationTargetNamespaceUID tracks the UID of the target namespace.
AnnotationTargetNamespaceUID = Domain + "/target-namespace-uid"
// AnnotationDeletionAttempts tracks number of failed deletion attempts.
AnnotationDeletionAttempts = Domain + "/deletion-attempts"
// Finalizers
// FinalizerName is the finalizer added to source resources.
FinalizerName = Domain + "/finalizer"
// Controller Configuration
// ControllerName is the name of the controller (for field manager, metrics, etc.).
ControllerName = "kubemirror"
// LeaderElectionID is the name of the leader election lease.
LeaderElectionID = "kubemirror-controller-leader"
// Special Values
// TargetNamespacesAll is the special keyword for mirroring to all namespaces.
TargetNamespacesAll = "all"
// TargetNamespacesAllLabeled mirrors to namespaces with allow-mirrors label.
TargetNamespacesAllLabeled = "all-labeled"
)
// Default System Namespaces (excluded by default)
var (
DefaultExcludedNamespaces = []string{
"kube-system",
"kube-public",
"kube-node-lease",
}
// Blacklisted Secret Types (never mirrored)
BlacklistedSecretTypes = []string{
"kubernetes.io/service-account-token",
"bootstrap.kubernetes.io/token",
"helm.sh/release.v1",
}
// Default Denied Resource Types
DefaultDeniedResourceTypes = []string{
"events",
"pods",
"replicasets",
"endpoints",
"endpointslices",
}
)
+277
View File
@@ -0,0 +1,277 @@
// Package controller implements the kubemirror reconciliation logic.
package controller
import (
"fmt"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/hash"
)
// CreateMirror creates a mirror resource in the target namespace.
// It copies the source resource's spec/data and adds ownership annotations.
func CreateMirror(source runtime.Object, targetNamespace string) (runtime.Object, error) {
// Compute content hash of source
sourceHash, err := hash.ComputeContentHash(source)
if err != nil {
return nil, fmt.Errorf("failed to compute source hash: %w", err)
}
// Handle typed resources
switch src := source.(type) {
case *corev1.Secret:
return createSecretMirror(src, targetNamespace, sourceHash)
case *corev1.ConfigMap:
return createConfigMapMirror(src, targetNamespace, sourceHash)
default:
// For unstructured/CRD resources
return createUnstructuredMirror(source, targetNamespace, sourceHash)
}
}
// createSecretMirror creates a mirror of a Secret.
func createSecretMirror(source *corev1.Secret, targetNamespace, sourceHash string) (*corev1.Secret, error) {
mirror := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: source.Name,
Namespace: targetNamespace,
Labels: map[string]string{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
Annotations: buildMirrorAnnotations(source, sourceHash),
},
Type: source.Type,
Data: source.Data,
// Note: Don't copy StringData as it's write-only and gets converted to Data
}
return mirror, nil
}
// createConfigMapMirror creates a mirror of a ConfigMap.
func createConfigMapMirror(source *corev1.ConfigMap, targetNamespace, sourceHash string) (*corev1.ConfigMap, error) {
mirror := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: source.Name,
Namespace: targetNamespace,
Labels: map[string]string{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
Annotations: buildMirrorAnnotations(source, sourceHash),
},
Data: source.Data,
BinaryData: source.BinaryData,
}
return mirror, nil
}
// filterKubeMirrorMetadata removes all kubemirror.raczylo.com/* keys from metadata.
// This prevents source kubemirror labels/annotations from being copied to mirrors.
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/") {
filtered[k] = v
}
}
return filtered
}
// createUnstructuredMirror creates a mirror of an unstructured resource (CRD).
func createUnstructuredMirror(source runtime.Object, targetNamespace, sourceHash string) (*unstructured.Unstructured, error) {
// Convert to unstructured
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(source)
if err != nil {
return nil, fmt.Errorf("failed to convert to unstructured: %w", err)
}
u := &unstructured.Unstructured{Object: unstructuredObj}
// Create mirror
mirror := u.DeepCopy()
mirror.SetNamespace(targetNamespace)
// Remove kubemirror labels from source (don't propagate to mirrors)
labels := mirror.GetLabels()
if labels == nil {
labels = make(map[string]string)
}
labels = filterKubeMirrorMetadata(labels)
labels[constants.LabelManagedBy] = constants.ControllerName
labels[constants.LabelMirror] = "true"
mirror.SetLabels(labels)
// Remove kubemirror annotations from source (don't propagate to mirrors)
existingAnnotations := mirror.GetAnnotations()
if existingAnnotations == nil {
existingAnnotations = make(map[string]string)
}
existingAnnotations = filterKubeMirrorMetadata(existingAnnotations)
// Add mirror-specific annotations
annotations := buildMirrorAnnotations(source, sourceHash)
for k, v := range annotations {
existingAnnotations[k] = v
}
mirror.SetAnnotations(existingAnnotations)
// Remove status (never mirror status)
unstructured.RemoveNestedField(mirror.Object, "status")
// Clear resource-specific metadata
mirror.SetResourceVersion("")
mirror.SetUID("")
mirror.SetGeneration(0)
mirror.SetCreationTimestamp(metav1.Time{})
mirror.SetFinalizers(nil) // Mirrors should not have finalizers
return mirror, nil
}
// buildMirrorAnnotations builds the ownership annotations for a mirror resource.
func buildMirrorAnnotations(source runtime.Object, sourceHash string) map[string]string {
sourceObj, _ := source.(metav1.Object)
annotations := map[string]string{
constants.AnnotationSourceNamespace: sourceObj.GetNamespace(),
constants.AnnotationSourceName: sourceObj.GetName(),
constants.AnnotationSourceUID: string(sourceObj.GetUID()),
constants.AnnotationSourceContentHash: sourceHash,
constants.AnnotationLastSyncTime: time.Now().UTC().Format(time.RFC3339),
}
// Add generation if available
if sourceObj.GetGeneration() > 0 {
annotations[constants.AnnotationSourceGeneration] = fmt.Sprintf("%d", sourceObj.GetGeneration())
}
// Add resource version for debugging
if sourceObj.GetResourceVersion() != "" {
annotations[constants.AnnotationSourceResourceVersion] = sourceObj.GetResourceVersion()
}
return annotations
}
// UpdateMirror updates an existing mirror with new source content.
func UpdateMirror(mirror, source runtime.Object) error {
// Compute new source hash
sourceHash, err := hash.ComputeContentHash(source)
if err != nil {
return fmt.Errorf("failed to compute source hash: %w", err)
}
// Update based on type
switch m := mirror.(type) {
case *corev1.Secret:
src := source.(*corev1.Secret)
m.Data = src.Data
m.Type = src.Type
updateMirrorAnnotations(m, source, sourceHash)
case *corev1.ConfigMap:
src := source.(*corev1.ConfigMap)
m.Data = src.Data
m.BinaryData = src.BinaryData
updateMirrorAnnotations(m, source, sourceHash)
default:
// Unstructured
return updateUnstructuredMirror(mirror, source, sourceHash)
}
return nil
}
// updateMirrorAnnotations updates the ownership annotations on a mirror.
func updateMirrorAnnotations(mirror metav1.Object, source runtime.Object, sourceHash string) {
sourceObj, _ := source.(metav1.Object)
annotations := mirror.GetAnnotations()
if annotations == nil {
annotations = make(map[string]string)
}
annotations[constants.AnnotationSourceContentHash] = sourceHash
annotations[constants.AnnotationLastSyncTime] = time.Now().UTC().Format(time.RFC3339)
if sourceObj.GetGeneration() > 0 {
annotations[constants.AnnotationSourceGeneration] = fmt.Sprintf("%d", sourceObj.GetGeneration())
}
if sourceObj.GetResourceVersion() != "" {
annotations[constants.AnnotationSourceResourceVersion] = sourceObj.GetResourceVersion()
}
mirror.SetAnnotations(annotations)
}
// updateUnstructuredMirror updates an unstructured mirror.
func updateUnstructuredMirror(mirror, source runtime.Object, sourceHash string) error {
m := mirror.(*unstructured.Unstructured)
s := source.(*unstructured.Unstructured)
// Update spec
sourceSpec, found, err := unstructured.NestedMap(s.Object, "spec")
if err != nil {
return fmt.Errorf("failed to get source spec: %w", err)
}
if found {
if err := unstructured.SetNestedMap(m.Object, sourceSpec, "spec"); err != nil {
return fmt.Errorf("failed to set mirror spec: %w", err)
}
}
// Update annotations
updateMirrorAnnotations(m, source, sourceHash)
// Ensure mirrors never have finalizers (even if they were added before this fix)
m.SetFinalizers(nil)
return nil
}
// IsManagedByUs checks if a resource is managed by kubemirror.
func IsManagedByUs(obj metav1.Object) bool {
labels := obj.GetLabels()
if labels == nil {
return false
}
return labels[constants.LabelManagedBy] == constants.ControllerName
}
// IsMirrorResource checks if a resource is a mirror (not a source).
func IsMirrorResource(obj metav1.Object) bool {
labels := obj.GetLabels()
if labels == nil {
return false
}
return labels[constants.LabelMirror] == "true"
}
// GetSourceReference extracts the source reference from a mirror's annotations.
func GetSourceReference(mirror metav1.Object) (namespace, name, uid string, found bool) {
annotations := mirror.GetAnnotations()
if annotations == nil {
return "", "", "", false
}
namespace = annotations[constants.AnnotationSourceNamespace]
name = annotations[constants.AnnotationSourceName]
uid = annotations[constants.AnnotationSourceUID]
if namespace == "" || name == "" {
return "", "", "", false
}
return namespace, name, uid, true
}
+622
View File
@@ -0,0 +1,622 @@
package controller
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
)
func TestCreateMirror_Secret(t *testing.T) {
source := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
UID: "source-uid-123",
ResourceVersion: "100",
Generation: 5,
},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{
"password": []byte("secret123"),
},
}
mirror, err := CreateMirror(source, "app1")
require.NoError(t, err)
require.NotNil(t, mirror)
secretMirror, ok := mirror.(*corev1.Secret)
require.True(t, ok, "mirror should be a Secret")
// Verify mirror properties
assert.Equal(t, "test-secret", secretMirror.Name)
assert.Equal(t, "app1", secretMirror.Namespace)
assert.Equal(t, corev1.SecretTypeOpaque, secretMirror.Type)
assert.Equal(t, source.Data, secretMirror.Data)
// Verify ownership labels
assert.Equal(t, constants.ControllerName, secretMirror.Labels[constants.LabelManagedBy])
assert.Equal(t, "true", secretMirror.Labels[constants.LabelMirror])
// Verify ownership annotations
assert.Equal(t, "default", secretMirror.Annotations[constants.AnnotationSourceNamespace])
assert.Equal(t, "test-secret", secretMirror.Annotations[constants.AnnotationSourceName])
assert.Equal(t, "source-uid-123", secretMirror.Annotations[constants.AnnotationSourceUID])
assert.Equal(t, "5", secretMirror.Annotations[constants.AnnotationSourceGeneration])
assert.NotEmpty(t, secretMirror.Annotations[constants.AnnotationSourceContentHash])
assert.NotEmpty(t, secretMirror.Annotations[constants.AnnotationLastSyncTime])
}
func TestCreateMirror_ConfigMap(t *testing.T) {
source := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-config",
Namespace: "default",
UID: "config-uid-456",
ResourceVersion: "200",
},
Data: map[string]string{
"config.yaml": "setting: value",
},
BinaryData: map[string][]byte{
"binary": {0x00, 0x01, 0x02},
},
}
mirror, err := CreateMirror(source, "prod-ns")
require.NoError(t, err)
require.NotNil(t, mirror)
cmMirror, ok := mirror.(*corev1.ConfigMap)
require.True(t, ok, "mirror should be a ConfigMap")
// Verify mirror properties
assert.Equal(t, "test-config", cmMirror.Name)
assert.Equal(t, "prod-ns", cmMirror.Namespace)
assert.Equal(t, source.Data, cmMirror.Data)
assert.Equal(t, source.BinaryData, cmMirror.BinaryData)
// Verify ownership labels
assert.Equal(t, constants.ControllerName, cmMirror.Labels[constants.LabelManagedBy])
assert.Equal(t, "true", cmMirror.Labels[constants.LabelMirror])
// Verify ownership annotations
assert.Equal(t, "default", cmMirror.Annotations[constants.AnnotationSourceNamespace])
assert.Equal(t, "test-config", cmMirror.Annotations[constants.AnnotationSourceName])
assert.Equal(t, "config-uid-456", cmMirror.Annotations[constants.AnnotationSourceUID])
assert.NotEmpty(t, cmMirror.Annotations[constants.AnnotationSourceContentHash])
}
func TestCreateMirror_Unstructured(t *testing.T) {
source := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "traefik.io/v1alpha1",
"kind": "Middleware",
"metadata": map[string]interface{}{
"name": "test-middleware",
"namespace": "traefik",
"uid": "middleware-uid-789",
"resourceVersion": "300",
"generation": int64(3),
},
"spec": map[string]interface{}{
"basicAuth": map[string]interface{}{
"secret": "auth-secret",
},
},
"status": map[string]interface{}{
"condition": "Ready",
},
},
}
mirror, err := CreateMirror(source, "app-ns")
require.NoError(t, err)
require.NotNil(t, mirror)
uMirror, ok := mirror.(*unstructured.Unstructured)
require.True(t, ok, "mirror should be Unstructured")
// Verify mirror properties
assert.Equal(t, "test-middleware", uMirror.GetName())
assert.Equal(t, "app-ns", uMirror.GetNamespace())
// Verify spec is copied
spec, found, err := unstructured.NestedMap(uMirror.Object, "spec")
require.NoError(t, err)
require.True(t, found)
assert.NotNil(t, spec)
// Verify status is NOT copied
_, found, err = unstructured.NestedMap(uMirror.Object, "status")
require.NoError(t, err)
assert.False(t, found, "status should not be mirrored")
// Verify metadata is cleared
assert.Empty(t, uMirror.GetResourceVersion())
assert.Empty(t, uMirror.GetUID())
assert.Equal(t, int64(0), uMirror.GetGeneration())
// Verify ownership labels
assert.Equal(t, constants.ControllerName, uMirror.GetLabels()[constants.LabelManagedBy])
assert.Equal(t, "true", uMirror.GetLabels()[constants.LabelMirror])
// Verify ownership annotations
annotations := uMirror.GetAnnotations()
assert.Equal(t, "traefik", annotations[constants.AnnotationSourceNamespace])
assert.Equal(t, "test-middleware", annotations[constants.AnnotationSourceName])
assert.Equal(t, "middleware-uid-789", annotations[constants.AnnotationSourceUID])
assert.Equal(t, "3", annotations[constants.AnnotationSourceGeneration])
}
func TestUpdateMirror_Secret(t *testing.T) {
mirror := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "app1",
Labels: map[string]string{
constants.LabelManagedBy: constants.ControllerName,
},
Annotations: map[string]string{
constants.AnnotationSourceContentHash: "oldhash",
},
},
Data: map[string][]byte{
"password": []byte("old"),
},
Type: corev1.SecretTypeOpaque,
}
source := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
Generation: 10,
},
Data: map[string][]byte{
"password": []byte("new"),
},
Type: corev1.SecretTypeTLS,
}
err := UpdateMirror(mirror, source)
require.NoError(t, err)
// Verify data updated
assert.Equal(t, source.Data, mirror.Data)
assert.Equal(t, source.Type, mirror.Type)
// Verify hash updated
assert.NotEqual(t, "oldhash", mirror.Annotations[constants.AnnotationSourceContentHash])
assert.Equal(t, "10", mirror.Annotations[constants.AnnotationSourceGeneration])
assert.NotEmpty(t, mirror.Annotations[constants.AnnotationLastSyncTime])
}
func TestUpdateMirror_ConfigMap(t *testing.T) {
mirror := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-config",
Namespace: "app1",
Annotations: map[string]string{
constants.AnnotationSourceContentHash: "oldhash",
},
},
Data: map[string]string{
"key": "old",
},
}
source := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-config",
Namespace: "default",
},
Data: map[string]string{
"key": "new",
},
BinaryData: map[string][]byte{
"binary": {0xFF},
},
}
err := UpdateMirror(mirror, source)
require.NoError(t, err)
// Verify data updated
assert.Equal(t, source.Data, mirror.Data)
assert.Equal(t, source.BinaryData, mirror.BinaryData)
assert.NotEqual(t, "oldhash", mirror.Annotations[constants.AnnotationSourceContentHash])
}
func TestIsManagedByUs(t *testing.T) {
tests := []struct {
obj metav1.Object
name string
want bool
}{
{
name: "managed by us",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelManagedBy: constants.ControllerName,
},
},
},
want: true,
},
{
name: "not managed by us",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelManagedBy: "other-controller",
},
},
},
want: false,
},
{
name: "no labels",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{},
},
want: false,
},
{
name: "nil labels",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: nil,
},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsManagedByUs(tt.obj)
assert.Equal(t, tt.want, got)
})
}
}
func TestIsMirrorResource(t *testing.T) {
tests := []struct {
obj metav1.Object
name string
want bool
}{
{
name: "is mirror",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelMirror: "true",
},
},
},
want: true,
},
{
name: "not mirror",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelMirror: "false",
},
},
},
want: false,
},
{
name: "no labels",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsMirrorResource(tt.obj)
assert.Equal(t, tt.want, got)
})
}
}
func TestGetSourceReference(t *testing.T) {
tests := []struct {
name string
obj metav1.Object
wantNamespace string
wantName string
wantUID string
wantFound bool
}{
{
name: "valid source reference",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
constants.AnnotationSourceNamespace: "default",
constants.AnnotationSourceName: "my-secret",
constants.AnnotationSourceUID: "uid-123",
},
},
},
wantNamespace: "default",
wantName: "my-secret",
wantUID: "uid-123",
wantFound: true,
},
{
name: "missing annotations",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{},
},
wantFound: false,
},
{
name: "incomplete annotations - missing name",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
constants.AnnotationSourceNamespace: "default",
},
},
},
wantFound: false,
},
{
name: "incomplete annotations - missing namespace",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
constants.AnnotationSourceName: "my-secret",
},
},
},
wantFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotNS, gotName, gotUID, gotFound := GetSourceReference(tt.obj)
assert.Equal(t, tt.wantFound, gotFound)
if tt.wantFound {
assert.Equal(t, tt.wantNamespace, gotNS)
assert.Equal(t, tt.wantName, gotName)
assert.Equal(t, tt.wantUID, gotUID)
}
})
}
}
// Test that mirrors don't include sync annotations (prevent infinite loop)
func TestCreateMirror_NoSyncAnnotations(t *testing.T) {
source := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Namespace: "default",
Labels: map[string]string{
constants.LabelEnabled: "true",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "app1,app2",
constants.AnnotationExclude: "false",
constants.AnnotationRecreateOnImmutableChange: "true",
},
},
Data: map[string][]byte{"key": []byte("value")},
}
mirror, err := CreateMirror(source, "app1")
require.NoError(t, err)
secretMirror := mirror.(*corev1.Secret)
// Verify sync annotations are NOT copied
assert.NotContains(t, secretMirror.Annotations, constants.AnnotationSync)
assert.NotContains(t, secretMirror.Annotations, constants.AnnotationTargetNamespaces)
// Verify enabled label is NOT copied
assert.NotContains(t, secretMirror.Labels, constants.LabelEnabled)
// Verify ownership annotations ARE present
assert.Contains(t, secretMirror.Annotations, constants.AnnotationSourceNamespace)
}
// Benchmarks for critical paths
func BenchmarkCreateMirror_Secret(b *testing.B) {
source := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "bench-secret",
Namespace: "default",
UID: "uid-123",
ResourceVersion: "100",
Generation: 1,
},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{
"password": []byte("secret123"),
"username": []byte("admin"),
"token": []byte("abcdef123456"),
},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = CreateMirror(source, "target-ns")
}
}
func BenchmarkCreateMirror_ConfigMap(b *testing.B) {
source := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "bench-config",
Namespace: "default",
UID: "uid-456",
ResourceVersion: "200",
},
Data: map[string]string{
"config.yaml": "key1: value1\nkey2: value2\nkey3: value3",
"app.conf": "setting=value",
},
BinaryData: map[string][]byte{
"binary": {0x00, 0x01, 0x02, 0x03, 0x04},
},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = CreateMirror(source, "target-ns")
}
}
func BenchmarkCreateMirror_Unstructured(b *testing.B) {
source := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "traefik.io/v1alpha1",
"kind": "Middleware",
"metadata": map[string]interface{}{
"name": "bench-middleware",
"namespace": "traefik",
"uid": "uid-789",
"resourceVersion": "300",
"generation": int64(3),
},
"spec": map[string]interface{}{
"basicAuth": map[string]interface{}{
"secret": "auth-secret",
},
"headers": map[string]interface{}{
"customRequestHeaders": map[string]interface{}{
"X-Custom-Header": "value",
},
},
},
},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = CreateMirror(source, "target-ns")
}
}
func BenchmarkUpdateMirror_Secret(b *testing.B) {
mirror := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "app1",
Labels: map[string]string{
constants.LabelManagedBy: constants.ControllerName,
},
Annotations: map[string]string{
constants.AnnotationSourceContentHash: "oldhash",
},
},
Data: map[string][]byte{
"password": []byte("old"),
},
Type: corev1.SecretTypeOpaque,
}
source := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
Generation: 10,
},
Data: map[string][]byte{
"password": []byte("new"),
},
Type: corev1.SecretTypeOpaque,
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = UpdateMirror(mirror, source)
}
}
func BenchmarkUpdateMirror_ConfigMap(b *testing.B) {
mirror := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-config",
Namespace: "app1",
Annotations: map[string]string{
constants.AnnotationSourceContentHash: "oldhash",
},
},
Data: map[string]string{
"key": "old",
},
}
source := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-config",
Namespace: "default",
},
Data: map[string]string{
"key": "new",
},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = UpdateMirror(mirror, source)
}
}
func BenchmarkIsManagedByUs(b *testing.B) {
obj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelManagedBy: constants.ControllerName,
},
},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = IsManagedByUs(obj)
}
}
func BenchmarkGetSourceReference(b *testing.B) {
obj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
constants.AnnotationSourceNamespace: "default",
constants.AnnotationSourceName: "my-secret",
constants.AnnotationSourceUID: "uid-123",
},
},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _, _, _ = GetSourceReference(obj)
}
}
+57
View File
@@ -0,0 +1,57 @@
// Package controller implements the kubemirror reconciliation logic.
package controller
import (
"context"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
)
// KubernetesNamespaceLister implements NamespaceLister using the Kubernetes API.
type KubernetesNamespaceLister struct {
client client.Client
}
// NewKubernetesNamespaceLister creates a new KubernetesNamespaceLister.
func NewKubernetesNamespaceLister(client client.Client) *KubernetesNamespaceLister {
return &KubernetesNamespaceLister{
client: client,
}
}
// ListNamespaces returns all namespace names in the cluster.
func (k *KubernetesNamespaceLister) ListNamespaces(ctx context.Context) ([]string, error) {
namespaceList := &corev1.NamespaceList{}
if err := k.client.List(ctx, namespaceList); err != nil {
return nil, err
}
names := make([]string, 0, len(namespaceList.Items))
for _, ns := range namespaceList.Items {
names = append(names, ns.Name)
}
return names, nil
}
// ListAllowMirrorsNamespaces returns namespaces that have the allow-mirrors label.
func (k *KubernetesNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error) {
namespaceList := &corev1.NamespaceList{}
// List namespaces with the allow-mirrors label
if err := k.client.List(ctx, namespaceList, client.MatchingLabels{
constants.LabelAllowMirrors: "true",
}); err != nil {
return nil, err
}
names := make([]string, 0, len(namespaceList.Items))
for _, ns := range namespaceList.Items {
names = append(names, ns.Name)
}
return names, nil
}
+446
View File
@@ -0,0 +1,446 @@
// Package controller implements the kubemirror reconciliation logic.
package controller
import (
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
"github.com/lukaszraczylo/kubemirror/pkg/hash"
)
// SourceReconciler reconciles source resources that need mirroring.
type SourceReconciler struct {
client.Client
Scheme *runtime.Scheme
Config *config.Config
Filter *filter.NamespaceFilter
NamespaceLister NamespaceLister
GVK schema.GroupVersionKind // The resource type this reconciler handles
}
// NamespaceLister provides a list of all namespaces in the cluster.
// This interface allows for testing with mocks.
type NamespaceLister interface {
ListNamespaces(ctx context.Context) ([]string, error)
ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error)
}
// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch
// Reconcile processes a single source resource.
func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx).WithValues("namespace", req.Namespace, "name", req.Name)
// Fetch the source resource as unstructured (works for all resource types)
source := &unstructured.Unstructured{}
source.SetGroupVersionKind(r.GVK) // Set the GVK so the client knows what to fetch
if err := r.Get(ctx, req.NamespacedName, source); err != nil {
if errors.IsNotFound(err) {
// Resource deleted - nothing to do
return ctrl.Result{}, nil
}
logger.Error(err, "failed to get resource")
return ctrl.Result{}, err
}
sourceObj := source
// Check if this is a mirror resource (shouldn't reconcile mirrors as sources)
if IsMirrorResource(sourceObj) {
// Silently skip - mirrors reconcile via watch, not as sources
return ctrl.Result{}, nil
}
// Check if resource is enabled for mirroring
if !isEnabledForMirroring(sourceObj) {
// Silently skip - don't log as it would be too noisy
return r.handleDisabled(ctx, sourceObj)
}
// Handle deletion
if !sourceObj.GetDeletionTimestamp().IsZero() {
return r.handleDeletion(ctx, source, sourceObj)
}
// Add finalizer if not present
// source (*unstructured.Unstructured) already implements client.Object
if !controllerutil.ContainsFinalizer(source, constants.FinalizerName) {
controllerutil.AddFinalizer(source, constants.FinalizerName)
if err := r.Update(ctx, source); err != nil {
logger.Error(err, "failed to add finalizer")
return ctrl.Result{}, err
}
logger.V(1).Info("added finalizer")
}
// Get target namespaces
targetNamespaces, err := r.resolveTargetNamespaces(ctx, sourceObj)
if err != nil {
logger.Error(err, "failed to resolve target namespaces")
return ctrl.Result{}, err
}
if len(targetNamespaces) == 0 {
logger.V(1).Info("no target namespaces resolved")
return ctrl.Result{}, nil
}
logger.V(1).Info("reconciling mirrors", "targetCount", len(targetNamespaces))
// Reconcile each target namespace
var reconciledCount, errorCount int
for _, targetNs := range targetNamespaces {
if err := r.reconcileMirror(ctx, source, sourceObj, targetNs); err != nil {
logger.Error(err, "failed to reconcile mirror", "targetNamespace", targetNs)
errorCount++
} else {
reconciledCount++
}
}
// Update status annotation with last sync info
if err := r.updateLastSyncStatus(ctx, source, sourceObj, reconciledCount, errorCount); err != nil {
logger.Error(err, "failed to update sync status")
return ctrl.Result{}, err
}
logger.Info("reconciliation complete",
"reconciled", reconciledCount,
"errors", errorCount,
"total", len(targetNamespaces))
// Requeue if there were errors
if errorCount > 0 {
return ctrl.Result{Requeue: true}, fmt.Errorf("failed to reconcile %d/%d mirrors", errorCount, len(targetNamespaces))
}
return ctrl.Result{}, nil
}
// handleDeletion removes finalizer after cleaning up all mirrors.
func (r *SourceReconciler) handleDeletion(ctx context.Context, source runtime.Object, sourceObj metav1.Object) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// source (*unstructured.Unstructured) already implements client.Object
sourceUnstructured := source.(*unstructured.Unstructured)
if !controllerutil.ContainsFinalizer(sourceUnstructured, constants.FinalizerName) {
return ctrl.Result{}, nil
}
// Delete all mirrors
if err := r.deleteAllMirrors(ctx, sourceObj); err != nil {
logger.Error(err, "failed to delete mirrors")
return ctrl.Result{}, err
}
// Remove finalizer
controllerutil.RemoveFinalizer(sourceUnstructured, constants.FinalizerName)
if err := r.Update(ctx, sourceUnstructured); err != nil {
logger.Error(err, "failed to remove finalizer")
return ctrl.Result{}, err
}
logger.Info("finalizer removed, mirrors deleted")
return ctrl.Result{}, nil
}
// handleDisabled removes mirrors when a resource is disabled.
func (r *SourceReconciler) handleDisabled(ctx context.Context, sourceObj metav1.Object) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// Source is already a client.Object (unstructured implements it)
sourceClient := sourceObj.(client.Object)
// If resource has finalizer, clean up mirrors and remove it
if controllerutil.ContainsFinalizer(sourceClient, constants.FinalizerName) {
if err := r.deleteAllMirrors(ctx, sourceObj); err != nil {
logger.Error(err, "failed to delete mirrors for disabled resource")
return ctrl.Result{}, err
}
// Remove finalizer
controllerutil.RemoveFinalizer(sourceClient, constants.FinalizerName)
if err := r.Update(ctx, sourceClient); err != nil {
logger.Error(err, "failed to remove finalizer from disabled resource")
return ctrl.Result{}, err
}
logger.Info("mirrors deleted and finalizer removed for disabled resource")
}
return ctrl.Result{}, nil
}
// reconcileMirror creates or updates a mirror in the target namespace.
func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.Object, sourceObj metav1.Object, targetNs string) error {
logger := log.FromContext(ctx).WithValues("targetNamespace", targetNs)
// Try to get existing mirror as unstructured
sourceUnstructured := source.(*unstructured.Unstructured)
existing := &unstructured.Unstructured{}
existing.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
err := r.Get(ctx, client.ObjectKey{Namespace: targetNs, Name: sourceObj.GetName()}, existing)
if err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("failed to get existing mirror: %w", err)
}
if err == nil {
// Mirror exists - check if it's managed by us
if !IsManagedByUs(existing) {
logger.Info("target resource exists but not managed by kubemirror, skipping")
return nil
}
// Check if update is needed
needsSync, err := hash.NeedsSync(source, existing, existing.GetAnnotations())
if err != nil {
return fmt.Errorf("failed to check if sync needed: %w", err)
}
if !needsSync {
logger.V(1).Info("mirror is up to date")
return nil
}
// Update mirror
if err := UpdateMirror(existing, source); err != nil {
return fmt.Errorf("failed to update mirror: %w", err)
}
if err := r.Update(ctx, existing); err != nil {
return fmt.Errorf("failed to update mirror in cluster: %w", err)
}
logger.Info("mirror updated")
return nil
}
// Create new mirror
mirror, err := CreateMirror(source, targetNs)
if err != nil {
return fmt.Errorf("failed to create mirror: %w", err)
}
if err := r.Create(ctx, mirror.(client.Object)); err != nil {
return fmt.Errorf("failed to create mirror in cluster: %w", err)
}
logger.Info("mirror created")
return nil
}
// deleteAllMirrors deletes all mirrors for a source resource.
func (r *SourceReconciler) deleteAllMirrors(ctx context.Context, sourceObj metav1.Object) error {
logger := log.FromContext(ctx)
// List all namespaces
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
if err != nil {
return fmt.Errorf("failed to list namespaces: %w", err)
}
// Get GVK from source object
sourceUnstructured, ok := sourceObj.(*unstructured.Unstructured)
if !ok {
return fmt.Errorf("source object is not unstructured")
}
var deleteCount int
for _, ns := range allNamespaces {
// Skip source namespace
if ns == sourceObj.GetNamespace() {
continue
}
// Create mirror reference for deletion
mirror := &unstructured.Unstructured{}
mirror.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
mirror.SetNamespace(ns)
mirror.SetName(sourceObj.GetName())
err := r.Delete(ctx, mirror)
if err == nil {
deleteCount++
} else if !errors.IsNotFound(err) {
logger.Error(err, "failed to delete mirror", "namespace", ns)
}
}
logger.Info("deleted mirrors", "count", deleteCount)
return nil
}
// resolveTargetNamespaces determines which namespaces should receive mirrors.
func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceObj metav1.Object) ([]string, error) {
annotations := sourceObj.GetAnnotations()
if annotations == nil {
return nil, nil
}
targetNsAnnotation := annotations[constants.AnnotationTargetNamespaces]
if targetNsAnnotation == "" {
return nil, nil
}
// Parse patterns
patterns := filter.ParseTargetNamespaces(targetNsAnnotation)
if len(patterns) == 0 {
return nil, nil
}
// Get all namespaces
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list namespaces: %w", err)
}
// Get namespaces with allow-mirrors label
allowMirrorsNamespaces, err := r.NamespaceLister.ListAllowMirrorsNamespaces(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list allow-mirrors namespaces: %w", err)
}
// Resolve target namespaces
targetNamespaces := filter.ResolveTargetNamespaces(
patterns,
allNamespaces,
allowMirrorsNamespaces,
sourceObj.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
}
// updateLastSyncStatus updates the source resource's annotations with sync status.
func (r *SourceReconciler) updateLastSyncStatus(ctx context.Context, source runtime.Object, sourceObj metav1.Object, reconciledCount, errorCount int) error {
annotations := sourceObj.GetAnnotations()
if annotations == nil {
annotations = make(map[string]string)
}
annotations[constants.AnnotationSyncStatus] = fmt.Sprintf("reconciled:%d,errors:%d", reconciledCount, errorCount)
sourceObj.SetAnnotations(annotations)
// source (*unstructured.Unstructured) already implements client.Object
return r.Update(ctx, source.(*unstructured.Unstructured))
}
// isEnabledForMirroring checks if a resource has both the label and annotation for mirroring.
func isEnabledForMirroring(obj metav1.Object) bool {
// Check label
labels := obj.GetLabels()
if labels == nil || labels[constants.LabelEnabled] != "true" {
return false
}
// Check annotation
annotations := obj.GetAnnotations()
if annotations == nil || annotations[constants.AnnotationSync] != "true" {
return false
}
return true
}
// SetupWithManager sets up the controller with the Manager.
func (r *SourceReconciler) SetupWithManager(mgr ctrl.Manager) error {
// Build predicate to only watch resources with enabled label
// This reduces API server load by ~90%
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.Secret{}).
Complete(r)
}
// SetupWithManagerForResourceType sets up a controller for a specific resource type.
// This allows dynamic controller registration for any discovered resource type.
func (r *SourceReconciler) SetupWithManagerForResourceType(
mgr ctrl.Manager,
gvk schema.GroupVersionKind,
) error {
// Create an unstructured object for this GVK
obj := &unstructured.Unstructured{}
obj.SetGroupVersionKind(gvk)
// Create unique controller name including version to avoid collisions
// e.g., "HorizontalPodAutoscaler.v1.autoscaling"
controllerName := gvk.Kind + "." + gvk.Version
if gvk.Group != "" {
controllerName += "." + gvk.Group
}
// 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
Watches(
mirrorObj,
handler.EnqueueRequestsFromMapFunc(r.mapMirrorToSource),
builder.WithPredicates(mirrorDeletePredicate),
).
Complete(r)
}
// 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
if !IsMirrorResource(obj) {
return nil
}
// Get source reference from annotations
sourceNs, sourceName, _, found := GetSourceReference(obj)
if !found {
return nil
}
// Enqueue reconciliation request for the source
return []reconcile.Request{
{
NamespacedName: types.NamespacedName{
Namespace: sourceNs,
Name: sourceName,
},
},
}
}
+463
View File
@@ -0,0 +1,463 @@
package controller
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
)
// MockClient is a mock implementation of client.Client for testing.
type MockClient struct {
mock.Mock
}
func (m *MockClient) Get(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error {
args := m.Called(ctx, key, obj)
if args.Error(0) != nil {
return args.Error(0)
}
// Copy the mock object into obj
if mockObj := args.Get(1); mockObj != nil {
switch v := mockObj.(type) {
case *corev1.Secret:
*obj.(*corev1.Secret) = *v
case *corev1.ConfigMap:
*obj.(*corev1.ConfigMap) = *v
case *unstructured.Unstructured:
// Copy the unstructured object
*obj.(*unstructured.Unstructured) = *v
}
}
return nil
}
func (m *MockClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
args := m.Called(ctx, list, opts)
return args.Error(0)
}
func (m *MockClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
args := m.Called(ctx, obj, patch, opts)
return args.Error(0)
}
func (m *MockClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Status() client.StatusWriter {
args := m.Called()
return args.Get(0).(client.StatusWriter)
}
func (m *MockClient) Scheme() *runtime.Scheme {
args := m.Called()
return args.Get(0).(*runtime.Scheme)
}
func (m *MockClient) RESTMapper() meta.RESTMapper {
args := m.Called()
return args.Get(0).(meta.RESTMapper)
}
func (m *MockClient) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) {
args := m.Called(obj)
return args.Get(0).(schema.GroupVersionKind), args.Error(1)
}
func (m *MockClient) IsObjectNamespaced(obj runtime.Object) (bool, error) {
args := m.Called(obj)
return args.Bool(0), args.Error(1)
}
func (m *MockClient) SubResource(subResource string) client.SubResourceClient {
args := m.Called(subResource)
return args.Get(0).(client.SubResourceClient)
}
// MockNamespaceLister is a mock implementation of NamespaceLister for testing.
type MockNamespaceLister struct {
mock.Mock
}
func (m *MockNamespaceLister) ListNamespaces(ctx context.Context) ([]string, error) {
args := m.Called(ctx)
return args.Get(0).([]string), args.Error(1)
}
func (m *MockNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error) {
args := m.Called(ctx)
return args.Get(0).([]string), args.Error(1)
}
func TestIsEnabledForMirroring(t *testing.T) {
tests := []struct {
obj metav1.Object
name string
want bool
}{
{
name: "enabled with both label and annotation",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
},
want: true,
},
{
name: "missing label",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
},
want: false,
},
{
name: "missing annotation",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
},
},
want: false,
},
{
name: "label set to false",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "false",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
},
want: false,
},
{
name: "no labels or annotations",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isEnabledForMirroring(tt.obj)
assert.Equal(t, tt.want, got)
})
}
}
func TestSourceReconciler_resolveTargetNamespaces(t *testing.T) {
tests := []struct {
name string
sourceAnnotations map[string]string
allNamespaces []string
allowMirrorsNamespaces []string
sourceNamespace string
wantContains []string
wantNotContains []string
wantError bool
expectListCalls bool
}{
{
name: "no target annotation",
sourceAnnotations: map[string]string{
constants.AnnotationSync: "true",
},
allNamespaces: []string{"app1", "app2"},
sourceNamespace: "default",
wantContains: nil,
expectListCalls: false,
},
{
name: "single target namespace",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "app1",
},
allNamespaces: []string{"app1", "app2", "default"},
sourceNamespace: "default",
wantContains: []string{"app1"},
wantNotContains: []string{"app2", "default"},
expectListCalls: true,
},
{
name: "multiple target namespaces",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "app1,app2",
},
allNamespaces: []string{"app1", "app2", "app3", "default"},
sourceNamespace: "default",
wantContains: []string{"app1", "app2"},
wantNotContains: []string{"app3", "default"},
expectListCalls: true,
},
{
name: "all keyword",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "all",
},
allNamespaces: []string{"app1", "app2", "default"},
sourceNamespace: "default",
wantContains: []string{"app1", "app2"},
wantNotContains: []string{"default"}, // source excluded
expectListCalls: true,
},
{
name: "pattern matching",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "app-*",
},
allNamespaces: []string{"app-frontend", "app-backend", "prod-api", "default"},
sourceNamespace: "default",
wantContains: []string{"app-frontend", "app-backend"},
wantNotContains: []string{"prod-api", "default"},
expectListCalls: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockLister := new(MockNamespaceLister)
if tt.expectListCalls {
mockLister.On("ListNamespaces", mock.Anything).Return(tt.allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(tt.allowMirrorsNamespaces, nil)
}
r := &SourceReconciler{
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
}
sourceObj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: tt.sourceNamespace,
Annotations: tt.sourceAnnotations,
},
}
got, err := r.resolveTargetNamespaces(context.Background(), sourceObj)
if tt.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.wantContains != nil {
for _, ns := range tt.wantContains {
assert.Contains(t, got, ns)
}
}
if tt.wantNotContains != nil {
for _, ns := range tt.wantNotContains {
assert.NotContains(t, got, ns)
}
}
if tt.expectListCalls {
mockLister.AssertExpectations(t)
}
})
}
}
func TestSourceReconciler_Reconcile_MirrorResource(t *testing.T) {
// Test that mirrors are not reconciled as sources
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
GVK: schema.GroupVersionKind{
Group: "",
Version: "v1",
Kind: "Secret",
},
}
// Create a mirror resource (has the mirror label) as unstructured
mirrorSecret := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "app1",
"labels": map[string]interface{}{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
},
},
}
mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, mirrorSecret)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Namespace: "app1",
Name: "test-secret",
},
}
result, err := r.Reconcile(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
mockClient.AssertExpectations(t)
}
func TestSourceReconciler_Reconcile_NotFound(t *testing.T) {
// Test that deleted resources are handled gracefully
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
GVK: schema.GroupVersionKind{
Group: "",
Version: "v1",
Kind: "Secret",
},
}
notFoundErr := errors.NewNotFound(schema.GroupResource{
Group: "",
Resource: "secrets",
}, "test-secret")
mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*unstructured.Unstructured")).
Return(notFoundErr, (*unstructured.Unstructured)(nil))
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Namespace: "default",
Name: "test-secret",
},
}
result, err := r.Reconcile(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
mockClient.AssertExpectations(t)
}
// Benchmark tests for performance-critical paths
func BenchmarkIsEnabledForMirroring(b *testing.B) {
obj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = isEnabledForMirroring(obj)
}
}
func BenchmarkResolveTargetNamespaces(b *testing.B) {
mockLister := new(MockNamespaceLister)
allNamespaces := make([]string, 100)
for i := 0; i < 100; i++ {
allNamespaces[i] = fmt.Sprintf("namespace-%d", i)
}
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(allNamespaces[:50], nil)
r := &SourceReconciler{
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
}
sourceObj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
Annotations: map[string]string{
constants.AnnotationTargetNamespaces: "all",
},
},
}
ctx := context.Background()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = r.resolveTargetNamespaces(ctx, sourceObj)
}
}
+153
View File
@@ -0,0 +1,153 @@
// Package discovery provides automatic resource type discovery for Kubernetes clusters.
package discovery
import (
"context"
"fmt"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
"github.com/lukaszraczylo/kubemirror/pkg/config"
)
// ResourceDiscovery discovers all mirrorable resource types in a cluster.
type ResourceDiscovery struct {
discoveryClient discovery.DiscoveryInterface
}
// NewResourceDiscovery creates a new resource discovery client.
func NewResourceDiscovery(cfg *rest.Config) (*ResourceDiscovery, error) {
dc, err := discovery.NewDiscoveryClientForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create discovery client: %w", err)
}
return &ResourceDiscovery{
discoveryClient: dc,
}, nil
}
// DiscoverMirrorableResources discovers all resource types that can be mirrored.
// It filters out resources that shouldn't be mirrored based on a deny list.
func (d *ResourceDiscovery) DiscoverMirrorableResources(ctx context.Context) ([]config.ResourceType, error) {
// Get all API resources in the cluster
_, apiResourceLists, err := d.discoveryClient.ServerGroupsAndResources()
if err != nil {
// Partial errors are common (some APIs might not be fully available)
// Continue with what we have
if !discovery.IsGroupDiscoveryFailedError(err) {
return nil, fmt.Errorf("failed to discover API resources: %w", err)
}
}
var resources []config.ResourceType
seen := make(map[string]bool) // Deduplicate
for _, apiResourceList := range apiResourceLists {
gv, err := schema.ParseGroupVersion(apiResourceList.GroupVersion)
if err != nil {
continue
}
for _, apiResource := range apiResourceList.APIResources {
// Skip subresources (status, scale, etc.)
if strings.Contains(apiResource.Name, "/") {
continue
}
// Skip if not namespaced (we only mirror namespaced resources)
if !apiResource.Namespaced {
continue
}
// Skip if resource doesn't support required verbs
if !supportsRequiredVerbs(apiResource.Verbs) {
continue
}
// Skip denied resource types
if isDeniedResourceType(apiResource.Kind) {
continue
}
rt := config.ResourceType{
Group: gv.Group,
Version: gv.Version,
Kind: apiResource.Kind,
}
// Deduplicate by string representation
key := rt.String()
if seen[key] {
continue
}
seen[key] = true
resources = append(resources, rt)
}
}
return resources, nil
}
// supportsRequiredVerbs checks if a resource supports the verbs needed for mirroring.
func supportsRequiredVerbs(verbs metav1.Verbs) bool {
required := []string{"get", "list", "watch", "create", "update", "delete"}
verbSet := make(map[string]bool)
for _, v := range verbs {
verbSet[v] = true
}
for _, req := range required {
if !verbSet[req] {
return false
}
}
return true
}
// isDeniedResourceType checks if a resource type should never be mirrored.
var deniedKinds = map[string]bool{
// Kubernetes core resources that shouldn't be mirrored
"Pod": true,
"Node": true,
"Event": true,
"Endpoints": true,
"EndpointSlice": true,
"ComponentStatus": true,
"Binding": true,
"ReplicationController": true, // Deprecated, use Deployment
// Resources that are auto-generated or managed
"ControllerRevision": true,
"PodMetrics": true,
"NodeMetrics": true,
// Lease resources (used for leader election)
"Lease": true,
// CSI and storage resources
"CSIDriver": true,
"CSINode": true,
"CSIStorageCapacity": true,
"VolumeAttachment": true,
// Cluster-scoped resources that we filtered out but double-check
"Namespace": true,
"PersistentVolume": true,
"ClusterRole": true,
"ClusterRoleBinding": true,
"CustomResourceDefinition": true,
"APIService": true,
"ValidatingWebhookConfiguration": true,
"MutatingWebhookConfiguration": true,
}
func isDeniedResourceType(kind string) bool {
return deniedKinds[kind]
}
+88
View File
@@ -0,0 +1,88 @@
package discovery
import (
"testing"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestSupportsRequiredVerbs(t *testing.T) {
tests := []struct {
name string
verbs metav1.Verbs
want bool
}{
{
name: "all required verbs present",
verbs: metav1.Verbs{"get", "list", "watch", "create", "update", "patch", "delete"},
want: true,
},
{
name: "exact required verbs",
verbs: metav1.Verbs{"get", "list", "watch", "create", "update", "delete"},
want: true,
},
{
name: "missing create verb",
verbs: metav1.Verbs{"get", "list", "watch", "update", "delete"},
want: false,
},
{
name: "missing watch verb",
verbs: metav1.Verbs{"get", "list", "create", "update", "delete"},
want: false,
},
{
name: "read-only resource",
verbs: metav1.Verbs{"get", "list", "watch"},
want: false,
},
{
name: "empty verbs",
verbs: metav1.Verbs{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := supportsRequiredVerbs(tt.verbs)
assert.Equal(t, tt.want, got)
})
}
}
func TestIsDeniedResourceType(t *testing.T) {
tests := []struct {
name string
kind string
want bool
}{
// Should be denied
{name: "Pod", kind: "Pod", want: true},
{name: "Event", kind: "Event", want: true},
{name: "Endpoints", kind: "Endpoints", want: true},
{name: "Node", kind: "Node", want: true},
{name: "Lease", kind: "Lease", want: true},
{name: "Namespace", kind: "Namespace", want: true},
{name: "ClusterRole", kind: "ClusterRole", want: true},
// Should NOT be denied
{name: "Secret", kind: "Secret", want: false},
{name: "ConfigMap", kind: "ConfigMap", want: false},
{name: "Service", kind: "Service", want: false},
{name: "Ingress", kind: "Ingress", want: false},
{name: "Deployment", kind: "Deployment", want: false},
{name: "StatefulSet", kind: "StatefulSet", want: false},
{name: "Middleware", kind: "Middleware", want: false},
{name: "Certificate", kind: "Certificate", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isDeniedResourceType(tt.kind)
assert.Equal(t, tt.want, got, "isDeniedResourceType(%s) = %v, want %v", tt.kind, got, tt.want)
})
}
}
+191
View File
@@ -0,0 +1,191 @@
package discovery
import (
"context"
"fmt"
"sync"
"time"
"github.com/go-logr/logr"
"sigs.k8s.io/controller-runtime/pkg/log"
"github.com/lukaszraczylo/kubemirror/pkg/config"
)
// Manager handles periodic resource discovery and controller registration.
type Manager struct {
discovery *ResourceDiscovery
logger logr.Logger
currentResources []config.ResourceType
interval time.Duration
mu sync.RWMutex
}
// NewManager creates a new discovery manager.
func NewManager(discovery *ResourceDiscovery, interval time.Duration) *Manager {
return &Manager{
discovery: discovery,
interval: interval,
currentResources: []config.ResourceType{},
}
}
// Start begins periodic resource discovery.
// It performs an initial discovery immediately, then rediscovers on the specified interval.
func (m *Manager) Start(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("discovery-manager")
m.logger = logger
// Initial discovery
if err := m.discover(ctx); err != nil {
return fmt.Errorf("initial resource discovery failed: %w", err)
}
// Start periodic rediscovery
go m.run(ctx)
return nil
}
// GetCurrentResources returns the currently discovered resource types.
func (m *Manager) GetCurrentResources() []config.ResourceType {
m.mu.RLock()
defer m.mu.RUnlock()
// Return a copy to prevent concurrent modification
result := make([]config.ResourceType, len(m.currentResources))
copy(result, m.currentResources)
return result
}
// run is the main discovery loop.
func (m *Manager) run(ctx context.Context) {
ticker := time.NewTicker(m.interval)
defer ticker.Stop()
logger := log.FromContext(ctx).WithName("discovery-manager")
for {
select {
case <-ctx.Done():
logger.Info("discovery manager stopped due to context cancellation")
return
case <-ticker.C:
if err := m.discover(ctx); err != nil {
logger.Error(err, "periodic resource discovery failed")
}
}
}
}
// discover performs resource discovery and detects changes.
func (m *Manager) discover(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("discovery-manager")
// Discover current resources
discovered, err := m.discovery.DiscoverMirrorableResources(ctx)
if err != nil {
return fmt.Errorf("failed to discover resources: %w", err)
}
m.mu.Lock()
defer m.mu.Unlock()
// Detect changes
added, removed := m.detectChanges(m.currentResources, discovered)
if len(added) > 0 {
logger.Info("new resource types discovered",
"count", len(added),
"resources", resourceTypesToStrings(added),
)
}
if len(removed) > 0 {
logger.Info("resource types removed",
"count", len(removed),
"resources", resourceTypesToStrings(removed),
)
}
if len(added) == 0 && len(removed) == 0 {
logger.V(1).Info("no changes in discovered resources",
"total", len(discovered),
)
}
// Update current resources
m.currentResources = discovered
logger.Info("resource discovery completed",
"total", len(discovered),
"added", len(added),
"removed", len(removed),
)
return nil
}
// detectChanges compares old and new resource lists to find additions and removals.
func (m *Manager) detectChanges(old, new []config.ResourceType) (added, removed []config.ResourceType) {
oldMap := make(map[string]config.ResourceType)
newMap := make(map[string]config.ResourceType)
for _, rt := range old {
oldMap[rt.String()] = rt
}
for _, rt := range new {
newMap[rt.String()] = rt
}
// Find added resources
for key, rt := range newMap {
if _, exists := oldMap[key]; !exists {
added = append(added, rt)
}
}
// Find removed resources
for key, rt := range oldMap {
if _, exists := newMap[key]; !exists {
removed = append(removed, rt)
}
}
return added, removed
}
// resourceTypesToStrings converts a slice of ResourceType to strings for logging.
func resourceTypesToStrings(resources []config.ResourceType) []string {
result := make([]string, len(resources))
for i, rt := range resources {
result[i] = rt.String()
}
return result
}
// WaitForInitialDiscovery blocks until the first discovery completes.
// Useful for ensuring resources are discovered before starting controllers.
func (m *Manager) WaitForInitialDiscovery(ctx context.Context, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("timeout waiting for initial discovery")
case <-ticker.C:
m.mu.RLock()
hasResources := len(m.currentResources) > 0
m.mu.RUnlock()
if hasResources {
return nil
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
package discovery
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/lukaszraczylo/kubemirror/pkg/config"
)
func TestDetectChanges(t *testing.T) {
m := &Manager{}
tests := []struct {
name string
old []config.ResourceType
new []config.ResourceType
wantAdded []config.ResourceType
wantRemoved []config.ResourceType
}{
{
name: "no changes",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
new: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
wantAdded: nil,
wantRemoved: nil,
},
{
name: "new resource added",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
},
new: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "Service", Version: "v1", Group: ""},
},
wantAdded: []config.ResourceType{
{Kind: "Service", Version: "v1", Group: ""},
},
wantRemoved: nil,
},
{
name: "resource removed",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
new: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
},
wantAdded: nil,
wantRemoved: []config.ResourceType{
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
},
{
name: "multiple changes",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
new: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "Service", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
},
wantAdded: []config.ResourceType{
{Kind: "Service", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
},
wantRemoved: []config.ResourceType{
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
},
{
name: "complete replacement",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
new: []config.ResourceType{
{Kind: "Service", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
},
wantAdded: []config.ResourceType{
{Kind: "Service", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
},
wantRemoved: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
},
{
name: "from empty to populated",
old: []config.ResourceType{},
new: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
wantAdded: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
wantRemoved: nil,
},
{
name: "from populated to empty",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
new: []config.ResourceType{},
wantAdded: nil,
wantRemoved: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotAdded, gotRemoved := m.detectChanges(tt.old, tt.new)
// Sort for consistent comparison
assert.ElementsMatch(t, tt.wantAdded, gotAdded, "added resources mismatch")
assert.ElementsMatch(t, tt.wantRemoved, gotRemoved, "removed resources mismatch")
})
}
}
func TestResourceTypesToStrings(t *testing.T) {
resources := []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
{Kind: "Middleware", Version: "v1alpha1", Group: "traefik.io"},
}
want := []string{
"Secret.v1",
"Ingress.v1.networking.k8s.io",
"Middleware.v1alpha1.traefik.io",
}
got := resourceTypesToStrings(resources)
assert.Equal(t, want, got)
}
+169
View File
@@ -0,0 +1,169 @@
// Package filter provides namespace filtering and pattern matching functionality.
package filter
import (
"path/filepath"
"strings"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
)
// NamespaceFilter handles namespace filtering logic including patterns and exclusions.
type NamespaceFilter struct {
excludedNamespaces map[string]bool
includedPatterns []string
}
// NewNamespaceFilter creates a new NamespaceFilter with the given exclusions and inclusions.
func NewNamespaceFilter(excluded, included []string) *NamespaceFilter {
excludedMap := make(map[string]bool)
for _, ns := range excluded {
excludedMap[ns] = true
}
return &NamespaceFilter{
excludedNamespaces: excludedMap,
includedPatterns: included,
}
}
// IsAllowed checks if a namespace is allowed based on filters.
// Returns true if the namespace passes all filters.
func (nf *NamespaceFilter) IsAllowed(namespace string) bool {
// Check if explicitly excluded
if nf.excludedNamespaces[namespace] {
return false
}
// If no include patterns specified, allow all (except excluded)
if len(nf.includedPatterns) == 0 {
return true
}
// Check if matches any include pattern
for _, pattern := range nf.includedPatterns {
if matchesPattern(namespace, pattern) {
return true
}
}
return false
}
// MatchesPattern checks if a namespace name matches the given pattern.
// Supports glob-style patterns: "app-*", "*-prod", "stage-*-db"
func matchesPattern(namespace, pattern string) bool {
// Direct match
if namespace == pattern {
return true
}
// Use filepath.Match for glob-style matching
// filepath.Match supports * (any sequence) and ? (single char)
matched, err := filepath.Match(pattern, namespace)
if err != nil {
// Invalid pattern, no match
return false
}
return matched
}
// ParseTargetNamespaces parses the target-namespaces annotation value.
// Returns a list of namespace patterns or special keywords.
// Input: "ns1,ns2,app-*" or "all" or "all-labeled"
func ParseTargetNamespaces(value string) []string {
if value == "" {
return nil
}
// Trim whitespace
value = strings.TrimSpace(value)
// Handle special keywords
if value == constants.TargetNamespacesAll || value == constants.TargetNamespacesAllLabeled {
return []string{value}
}
// Split by comma and trim each entry
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
return result
}
// ResolveTargetNamespaces resolves namespace patterns to concrete namespace names.
// Handles "all", "all-labeled", and glob patterns.
// Parameters:
// - patterns: namespace patterns from annotation
// - allNamespaces: list of all namespaces in cluster
// - allowMirrorsNamespaces: namespaces with allow-mirrors label
// - sourceNamespace: exclude this namespace to prevent self-copy
// - filter: namespace filter for exclusions
//
// Returns: list of concrete target namespace names
func ResolveTargetNamespaces(
patterns []string,
allNamespaces []string,
allowMirrorsNamespaces []string,
sourceNamespace string,
filter *NamespaceFilter,
) []string {
if len(patterns) == 0 {
return nil
}
// Use map to deduplicate
targetMap := make(map[string]bool)
for _, pattern := range patterns {
switch pattern {
case constants.TargetNamespacesAll:
// Mirror to all namespaces (except source and excluded)
for _, ns := range allNamespaces {
if ns != sourceNamespace && filter.IsAllowed(ns) {
targetMap[ns] = true
}
}
case constants.TargetNamespacesAllLabeled:
// Mirror only to namespaces with allow-mirrors label
for _, ns := range allowMirrorsNamespaces {
if ns != sourceNamespace && filter.IsAllowed(ns) {
targetMap[ns] = true
}
}
default:
// Check if it's a pattern or direct namespace name
if strings.Contains(pattern, "*") || strings.Contains(pattern, "?") {
// It's a glob pattern - match against all namespaces
for _, ns := range allNamespaces {
if matchesPattern(ns, pattern) && ns != sourceNamespace && filter.IsAllowed(ns) {
targetMap[ns] = true
}
}
} else {
// Direct namespace name
if pattern != sourceNamespace && filter.IsAllowed(pattern) {
targetMap[pattern] = true
}
}
}
}
// Convert map to slice
result := make([]string, 0, len(targetMap))
for ns := range targetMap {
result = append(result, ns)
}
return result
}
+587
View File
@@ -0,0 +1,587 @@
package filter
import (
"fmt"
"testing"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/stretchr/testify/assert"
)
func TestNamespaceFilter_IsAllowed(t *testing.T) {
tests := []struct {
name string
namespace string
excluded []string
included []string
want bool
}{
{
name: "allow when no filters",
excluded: []string{},
included: []string{},
namespace: "app1",
want: true,
},
{
name: "deny when explicitly excluded",
excluded: []string{"kube-system", "kube-public"},
included: []string{},
namespace: "kube-system",
want: false,
},
{
name: "allow when not excluded",
excluded: []string{"kube-system"},
included: []string{},
namespace: "app1",
want: true,
},
{
name: "allow when matches include pattern",
excluded: []string{},
included: []string{"app-*"},
namespace: "app-frontend",
want: true,
},
{
name: "deny when doesn't match include pattern",
excluded: []string{},
included: []string{"app-*"},
namespace: "backend",
want: false,
},
{
name: "deny when excluded even if matches include",
excluded: []string{"app-bad"},
included: []string{"app-*"},
namespace: "app-bad",
want: false,
},
{
name: "allow when matches one of multiple patterns",
excluded: []string{},
included: []string{"app-*", "prod-*"},
namespace: "prod-db",
want: true,
},
{
name: "allow direct match in include list",
excluded: []string{},
included: []string{"specific-ns"},
namespace: "specific-ns",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
nf := NewNamespaceFilter(tt.excluded, tt.included)
got := nf.IsAllowed(tt.namespace)
assert.Equal(t, tt.want, got)
})
}
}
func TestMatchesPattern(t *testing.T) {
tests := []struct {
name string
namespace string
pattern string
want bool
}{
{
name: "exact match",
namespace: "app-frontend",
pattern: "app-frontend",
want: true,
},
{
name: "wildcard at end",
namespace: "app-frontend",
pattern: "app-*",
want: true,
},
{
name: "wildcard at start",
namespace: "app-frontend",
pattern: "*-frontend",
want: true,
},
{
name: "wildcard in middle",
namespace: "app-prod-frontend",
pattern: "app-*-frontend",
want: true,
},
{
name: "multiple wildcards",
namespace: "my-app-prod-db",
pattern: "*-app-*-db",
want: true,
},
{
name: "single char wildcard",
namespace: "app1",
pattern: "app?",
want: true,
},
{
name: "no match",
namespace: "backend",
pattern: "app-*",
want: false,
},
{
name: "empty pattern matches empty string",
namespace: "",
pattern: "",
want: true,
},
{
name: "pattern doesn't match different namespace",
namespace: "production-app",
pattern: "prod-*",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchesPattern(tt.namespace, tt.pattern)
assert.Equal(t, tt.want, got)
})
}
}
func TestParseTargetNamespaces(t *testing.T) {
tests := []struct {
name string
value string
want []string
}{
{
name: "empty string",
value: "",
want: nil,
},
{
name: "single namespace",
value: "app1",
want: []string{"app1"},
},
{
name: "multiple namespaces",
value: "app1,app2,app3",
want: []string{"app1", "app2", "app3"},
},
{
name: "with whitespace",
value: "app1, app2 , app3",
want: []string{"app1", "app2", "app3"},
},
{
name: "special keyword 'all'",
value: "all",
want: []string{"all"},
},
{
name: "special keyword 'all-labeled'",
value: "all-labeled",
want: []string{"all-labeled"},
},
{
name: "mixed patterns",
value: "app1,app-*,prod-*",
want: []string{"app1", "app-*", "prod-*"},
},
{
name: "trailing comma",
value: "app1,app2,",
want: []string{"app1", "app2"},
},
{
name: "empty entries ignored",
value: "app1,,app2",
want: []string{"app1", "app2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseTargetNamespaces(tt.value)
assert.Equal(t, tt.want, got)
})
}
}
func TestResolveTargetNamespaces(t *testing.T) {
allNamespaces := []string{"app1", "app2", "app-frontend", "app-backend", "prod-db", "prod-api", "kube-system", "default"}
allowMirrorsNamespaces := []string{"app1", "app-frontend", "prod-db"}
excludeFilter := NewNamespaceFilter([]string{"kube-system"}, []string{})
tests := []struct {
name string
patterns []string
allNamespaces []string
allowMirrorsNamespaces []string
sourceNamespace string
filter *NamespaceFilter
wantContains []string
wantNotContains []string
}{
{
name: "empty patterns",
patterns: []string{},
allNamespaces: allNamespaces,
allowMirrorsNamespaces: allowMirrorsNamespaces,
sourceNamespace: "default",
filter: excludeFilter,
wantContains: []string{},
wantNotContains: allNamespaces,
},
{
name: "all keyword",
patterns: []string{constants.TargetNamespacesAll},
allNamespaces: allNamespaces,
allowMirrorsNamespaces: allowMirrorsNamespaces,
sourceNamespace: "default",
filter: excludeFilter,
wantContains: []string{"app1", "app2", "app-frontend", "prod-db"},
wantNotContains: []string{"default", "kube-system"}, // excluded: source and kube-system
},
{
name: "all-labeled keyword",
patterns: []string{constants.TargetNamespacesAllLabeled},
allNamespaces: allNamespaces,
allowMirrorsNamespaces: allowMirrorsNamespaces,
sourceNamespace: "default",
filter: excludeFilter,
wantContains: []string{"app1", "app-frontend", "prod-db"},
wantNotContains: []string{"app2", "app-backend", "default"},
},
{
name: "glob pattern app-*",
patterns: []string{"app-*"},
allNamespaces: allNamespaces,
allowMirrorsNamespaces: allowMirrorsNamespaces,
sourceNamespace: "default",
filter: excludeFilter,
wantContains: []string{"app-frontend", "app-backend"},
wantNotContains: []string{"app1", "app2", "prod-db"},
},
{
name: "multiple patterns",
patterns: []string{"app-*", "prod-*"},
allNamespaces: allNamespaces,
allowMirrorsNamespaces: allowMirrorsNamespaces,
sourceNamespace: "default",
filter: excludeFilter,
wantContains: []string{"app-frontend", "app-backend", "prod-db", "prod-api"},
wantNotContains: []string{"app1", "app2", "default"},
},
{
name: "direct namespace names",
patterns: []string{"app1", "app2"},
allNamespaces: allNamespaces,
allowMirrorsNamespaces: allowMirrorsNamespaces,
sourceNamespace: "default",
filter: excludeFilter,
wantContains: []string{"app1", "app2"},
wantNotContains: []string{"app-frontend", "prod-db", "default"},
},
{
name: "exclude source namespace",
patterns: []string{"app1"},
allNamespaces: allNamespaces,
allowMirrorsNamespaces: allowMirrorsNamespaces,
sourceNamespace: "app1",
filter: excludeFilter,
wantContains: []string{},
wantNotContains: []string{"app1"}, // app1 is source, excluded
},
{
name: "deduplication",
patterns: []string{"app-*", "app-frontend"}, // app-frontend matches both
allNamespaces: allNamespaces,
allowMirrorsNamespaces: allowMirrorsNamespaces,
sourceNamespace: "default",
filter: excludeFilter,
wantContains: []string{"app-frontend", "app-backend"},
wantNotContains: []string{"app1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ResolveTargetNamespaces(
tt.patterns,
tt.allNamespaces,
tt.allowMirrorsNamespaces,
tt.sourceNamespace,
tt.filter,
)
// Check that all expected namespaces are present
for _, ns := range tt.wantContains {
assert.Contains(t, got, ns, "should contain %s", ns)
}
// Check that unwanted namespaces are not present
for _, ns := range tt.wantNotContains {
assert.NotContains(t, got, ns, "should not contain %s", ns)
}
})
}
}
// Edge case tests
func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
t.Run("no namespaces in cluster", func(t *testing.T) {
got := ResolveTargetNamespaces(
[]string{"all"},
[]string{},
[]string{},
"default",
NewNamespaceFilter([]string{}, []string{}),
)
assert.Empty(t, got)
})
t.Run("invalid pattern doesn't crash", func(t *testing.T) {
// filepath.Match should handle this gracefully
got := ResolveTargetNamespaces(
[]string{"[invalid"},
[]string{"app1"},
[]string{},
"default",
NewNamespaceFilter([]string{}, []string{}),
)
assert.NotNil(t, got)
})
t.Run("all excludes everything when filter denies all", func(t *testing.T) {
strictFilter := NewNamespaceFilter([]string{}, []string{"specific-ns"})
got := ResolveTargetNamespaces(
[]string{"all"},
[]string{"app1", "app2", "app3"},
[]string{},
"default",
strictFilter,
)
// Only "specific-ns" would be allowed, but it's not in allNamespaces
assert.Empty(t, got)
})
}
// Benchmark tests for critical paths
func BenchmarkParseTargetNamespaces(b *testing.B) {
tests := []struct {
name string
value string
}{
{
name: "single namespace",
value: "app1",
},
{
name: "10 namespaces",
value: "app1,app2,app3,app4,app5,app6,app7,app8,app9,app10",
},
{
name: "50 namespaces with whitespace",
value: "app1, app2, app3, app4, app5, app6, app7, app8, app9, app10, app11, app12, app13, app14, app15, app16, app17, app18, app19, app20, app21, app22, app23, app24, app25, app26, app27, app28, app29, app30, app31, app32, app33, app34, app35, app36, app37, app38, app39, app40, app41, app42, app43, app44, app45, app46, app47, app48, app49, app50",
},
{
name: "mixed patterns",
value: "app1,app-*,prod-*,staging-*,dev-*",
},
}
for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = ParseTargetNamespaces(tt.value)
}
})
}
}
func BenchmarkMatchesPattern(b *testing.B) {
tests := []struct {
name string
namespace string
pattern string
}{
{
name: "exact match",
namespace: "app-frontend",
pattern: "app-frontend",
},
{
name: "simple wildcard",
namespace: "app-frontend",
pattern: "app-*",
},
{
name: "complex wildcard",
namespace: "my-app-prod-db",
pattern: "*-app-*-db",
},
{
name: "no match",
namespace: "production-api",
pattern: "app-*",
},
}
for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = matchesPattern(tt.namespace, tt.pattern)
}
})
}
}
func BenchmarkNamespaceFilter_IsAllowed(b *testing.B) {
tests := []struct {
name string
filter *NamespaceFilter
namespace string
}{
{
name: "no filters (always allow)",
filter: NewNamespaceFilter([]string{}, []string{}),
namespace: "app1",
},
{
name: "simple exclusion",
filter: NewNamespaceFilter([]string{"kube-system", "kube-public", "kube-node-lease"}, []string{}),
namespace: "app1",
},
{
name: "pattern inclusion",
filter: NewNamespaceFilter([]string{}, []string{"app-*", "prod-*"}),
namespace: "app-frontend",
},
{
name: "complex filtering",
filter: NewNamespaceFilter([]string{"kube-system", "test-*"}, []string{"app-*", "prod-*"}),
namespace: "prod-api",
},
}
for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = tt.filter.IsAllowed(tt.namespace)
}
})
}
}
func BenchmarkResolveTargetNamespaces(b *testing.B) {
// Generate realistic namespace list
allNamespaces := make([]string, 100)
for i := 0; i < 100; i++ {
if i < 30 {
allNamespaces[i] = fmt.Sprintf("app-%d", i)
} else if i < 60 {
allNamespaces[i] = fmt.Sprintf("prod-%d", i)
} else if i < 90 {
allNamespaces[i] = fmt.Sprintf("staging-%d", i)
} else {
allNamespaces[i] = fmt.Sprintf("test-%d", i)
}
}
allowMirrorsNamespaces := allNamespaces[:50] // Half have opt-in label
filter := NewNamespaceFilter([]string{"kube-system", "kube-public"}, []string{})
tests := []struct {
name string
patterns []string
}{
{
name: "all keyword",
patterns: []string{constants.TargetNamespacesAll},
},
{
name: "all-labeled keyword",
patterns: []string{constants.TargetNamespacesAllLabeled},
},
{
name: "single pattern",
patterns: []string{"app-*"},
},
{
name: "multiple patterns",
patterns: []string{"app-*", "prod-*", "staging-*"},
},
{
name: "direct names",
patterns: []string{"app-1", "app-2", "prod-1", "prod-2"},
},
{
name: "mixed direct and patterns",
patterns: []string{"app-1", "prod-*", "staging-5"},
},
}
for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = ResolveTargetNamespaces(
tt.patterns,
allNamespaces,
allowMirrorsNamespaces,
"default",
filter,
)
}
})
}
}
func BenchmarkResolveTargetNamespaces_LargeScale(b *testing.B) {
// Simulate large cluster (1000 namespaces)
allNamespaces := make([]string, 1000)
for i := 0; i < 1000; i++ {
allNamespaces[i] = fmt.Sprintf("namespace-%d", i)
}
allowMirrorsNamespaces := allNamespaces[:500]
filter := NewNamespaceFilter(constants.DefaultExcludedNamespaces, []string{})
b.Run("1000 namespaces with 'all' keyword", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = ResolveTargetNamespaces(
[]string{constants.TargetNamespacesAll},
allNamespaces,
allowMirrorsNamespaces,
"default",
filter,
)
}
})
b.Run("1000 namespaces with pattern matching", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = ResolveTargetNamespaces(
[]string{"namespace-*"},
allNamespaces,
allowMirrorsNamespaces,
"default",
filter,
)
}
})
}
+144
View File
@@ -0,0 +1,144 @@
// Package hash provides content hashing functionality for detecting resource changes.
package hash
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
)
// ComputeContentHash computes a SHA256 hash of the resource's actual content.
// It excludes metadata fields (resourceVersion, managedFields, etc.) and status.
// This detects actual content changes vs Kubernetes metadata changes.
func ComputeContentHash(obj runtime.Object) (string, error) {
content, err := extractContent(obj)
if err != nil {
return "", fmt.Errorf("failed to extract content: %w", err)
}
// Convert to JSON for consistent hashing
jsonBytes, err := json.Marshal(content)
if err != nil {
return "", fmt.Errorf("failed to marshal content: %w", err)
}
// Compute SHA256
hash := sha256.Sum256(jsonBytes)
return hex.EncodeToString(hash[:]), nil
}
// extractContent extracts only the content fields from a resource.
// Excludes all metadata except name, namespace, labels, and annotations we care about.
func extractContent(obj runtime.Object) (interface{}, error) {
// Try typed resources first
switch resource := obj.(type) {
case *corev1.Secret:
return extractSecretContent(resource), nil
case *corev1.ConfigMap:
return extractConfigMapContent(resource), nil
default:
// Fall back to unstructured for CRDs and unknown types
return extractUnstructuredContent(obj)
}
}
// extractSecretContent extracts content from a Secret.
func extractSecretContent(secret *corev1.Secret) map[string]interface{} {
return map[string]interface{}{
"type": string(secret.Type),
"data": secret.Data,
"stringData": secret.StringData,
}
}
// extractConfigMapContent extracts content from a ConfigMap.
func extractConfigMapContent(cm *corev1.ConfigMap) map[string]interface{} {
return map[string]interface{}{
"data": cm.Data,
"binaryData": cm.BinaryData,
}
}
// extractUnstructuredContent extracts content from an unstructured resource (CRDs, etc.).
func extractUnstructuredContent(obj runtime.Object) (interface{}, error) {
// Convert to unstructured
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err != nil {
return nil, fmt.Errorf("failed to convert to unstructured: %w", err)
}
u := &unstructured.Unstructured{Object: unstructuredObj}
// Make a deep copy to avoid race conditions when accessing nested fields
// NestedMap modifies the underlying map, so we need our own copy
uCopy := u.DeepCopy()
// Extract spec (most resources have spec)
spec, found, err := unstructured.NestedMap(uCopy.Object, "spec")
if err != nil {
return nil, fmt.Errorf("failed to extract spec: %w", err)
}
content := make(map[string]interface{})
if found {
content["spec"] = spec
}
// For resources without spec, include all fields except metadata and status
if !found {
for key, value := range uCopy.Object {
if key != "metadata" && key != "status" && key != "apiVersion" && key != "kind" {
content[key] = value
}
}
}
return content, nil
}
// NeedsSync determines if a target resource needs to be synced based on content changes.
// It uses a multi-layer strategy:
// 1. Check generation field (if available) - fastest
// 2. Check content hash - universal
func NeedsSync(source, target runtime.Object, targetAnnotations map[string]string) (bool, error) {
// Layer 1: Generation-based check (for resources that support it)
sourceGen := getGeneration(source)
if sourceGen > 0 {
targetSourceGen := targetAnnotations["source-generation"]
if fmt.Sprintf("%d", sourceGen) != targetSourceGen {
return true, nil // Generation changed
}
}
// Layer 2: Content hash check (works for all resources)
sourceHash, err := ComputeContentHash(source)
if err != nil {
return false, fmt.Errorf("failed to compute source hash: %w", err)
}
targetSourceHash := targetAnnotations["source-content-hash"]
if sourceHash != targetSourceHash {
return true, nil // Content changed
}
// No changes detected
return false, nil
}
// getGeneration extracts the generation field from a resource if it exists.
// Returns 0 if the resource doesn't have a generation field.
func getGeneration(obj runtime.Object) int64 {
// Convert to unstructured to access generation
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err != nil {
return 0
}
u := &unstructured.Unstructured{Object: unstructuredObj}
return u.GetGeneration()
}
+529
View File
@@ -0,0 +1,529 @@
package hash
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
)
func TestComputeContentHash_Secret(t *testing.T) {
tests := []struct {
secret1 *corev1.Secret
secret2 *corev1.Secret
name string
wantSame bool
wantError bool
}{
{
name: "identical secrets produce same hash",
secret1: &corev1.Secret{
Data: map[string][]byte{
"password": []byte("secret123"),
},
Type: corev1.SecretTypeOpaque,
},
secret2: &corev1.Secret{
Data: map[string][]byte{
"password": []byte("secret123"),
},
Type: corev1.SecretTypeOpaque,
},
wantSame: true,
wantError: false,
},
{
name: "different data produces different hash",
secret1: &corev1.Secret{
Data: map[string][]byte{
"password": []byte("secret123"),
},
},
secret2: &corev1.Secret{
Data: map[string][]byte{
"password": []byte("different"),
},
},
wantSame: false,
wantError: false,
},
{
name: "different type produces different hash",
secret1: &corev1.Secret{
Data: map[string][]byte{"key": []byte("value")},
Type: corev1.SecretTypeOpaque,
},
secret2: &corev1.Secret{
Data: map[string][]byte{"key": []byte("value")},
Type: corev1.SecretTypeTLS,
},
wantSame: false,
wantError: false,
},
{
name: "metadata changes don't affect hash",
secret1: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "secret1",
Namespace: "default",
ResourceVersion: "100",
Generation: 1,
},
Data: map[string][]byte{"key": []byte("value")},
},
secret2: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "secret2",
Namespace: "different",
ResourceVersion: "200",
Generation: 2,
},
Data: map[string][]byte{"key": []byte("value")},
},
wantSame: true,
wantError: false,
},
{
name: "stringData included in hash",
secret1: &corev1.Secret{
StringData: map[string]string{"key": "value"},
},
secret2: &corev1.Secret{
StringData: map[string]string{"key": "different"},
},
wantSame: false,
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash1, err1 := ComputeContentHash(tt.secret1)
hash2, err2 := ComputeContentHash(tt.secret2)
if tt.wantError {
require.Error(t, err1)
require.Error(t, err2)
return
}
require.NoError(t, err1)
require.NoError(t, err2)
assert.NotEmpty(t, hash1)
assert.NotEmpty(t, hash2)
if tt.wantSame {
assert.Equal(t, hash1, hash2, "hashes should be identical")
} else {
assert.NotEqual(t, hash1, hash2, "hashes should be different")
}
})
}
}
func TestComputeContentHash_ConfigMap(t *testing.T) {
tests := []struct {
cm1 *corev1.ConfigMap
cm2 *corev1.ConfigMap
name string
wantSame bool
wantError bool
}{
{
name: "identical configmaps produce same hash",
cm1: &corev1.ConfigMap{
Data: map[string]string{
"config.yaml": "setting: value",
},
},
cm2: &corev1.ConfigMap{
Data: map[string]string{
"config.yaml": "setting: value",
},
},
wantSame: true,
wantError: false,
},
{
name: "different data produces different hash",
cm1: &corev1.ConfigMap{
Data: map[string]string{
"key": "value1",
},
},
cm2: &corev1.ConfigMap{
Data: map[string]string{
"key": "value2",
},
},
wantSame: false,
wantError: false,
},
{
name: "binaryData included in hash",
cm1: &corev1.ConfigMap{
BinaryData: map[string][]byte{
"file": []byte{0x00, 0x01, 0x02},
},
},
cm2: &corev1.ConfigMap{
BinaryData: map[string][]byte{
"file": []byte{0x00, 0x01, 0xFF},
},
},
wantSame: false,
wantError: false,
},
{
name: "metadata changes don't affect hash",
cm1: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
ResourceVersion: "100",
Generation: 1,
},
Data: map[string]string{"key": "value"},
},
cm2: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
ResourceVersion: "200",
Generation: 5,
},
Data: map[string]string{"key": "value"},
},
wantSame: true,
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash1, err1 := ComputeContentHash(tt.cm1)
hash2, err2 := ComputeContentHash(tt.cm2)
if tt.wantError {
require.Error(t, err1)
require.Error(t, err2)
return
}
require.NoError(t, err1)
require.NoError(t, err2)
assert.NotEmpty(t, hash1)
assert.NotEmpty(t, hash2)
if tt.wantSame {
assert.Equal(t, hash1, hash2)
} else {
assert.NotEqual(t, hash1, hash2)
}
})
}
}
func TestComputeContentHash_Unstructured(t *testing.T) {
tests := []struct {
obj1 *unstructured.Unstructured
obj2 *unstructured.Unstructured
name string
wantSame bool
wantError bool
}{
{
name: "identical specs produce same hash",
obj1: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Custom",
"spec": map[string]interface{}{
"field": "value",
},
},
},
obj2: &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Custom",
"spec": map[string]interface{}{
"field": "value",
},
},
},
wantSame: true,
wantError: false,
},
{
name: "different specs produce different hash",
obj1: &unstructured.Unstructured{
Object: map[string]interface{}{
"spec": map[string]interface{}{
"field": "value1",
},
},
},
obj2: &unstructured.Unstructured{
Object: map[string]interface{}{
"spec": map[string]interface{}{
"field": "value2",
},
},
},
wantSame: false,
wantError: false,
},
{
name: "metadata excluded from hash",
obj1: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"resourceVersion": "100",
},
"spec": map[string]interface{}{
"field": "value",
},
},
},
obj2: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"resourceVersion": "200",
},
"spec": map[string]interface{}{
"field": "value",
},
},
},
wantSame: true,
wantError: false,
},
{
name: "status excluded from hash",
obj1: &unstructured.Unstructured{
Object: map[string]interface{}{
"spec": map[string]interface{}{
"field": "value",
},
"status": map[string]interface{}{
"condition": "Ready",
},
},
},
obj2: &unstructured.Unstructured{
Object: map[string]interface{}{
"spec": map[string]interface{}{
"field": "value",
},
"status": map[string]interface{}{
"condition": "NotReady",
},
},
},
wantSame: true,
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash1, err1 := ComputeContentHash(tt.obj1)
hash2, err2 := ComputeContentHash(tt.obj2)
if tt.wantError {
require.Error(t, err1)
require.Error(t, err2)
return
}
require.NoError(t, err1)
require.NoError(t, err2)
assert.NotEmpty(t, hash1)
assert.NotEmpty(t, hash2)
if tt.wantSame {
assert.Equal(t, hash1, hash2)
} else {
assert.NotEqual(t, hash1, hash2)
}
})
}
}
func TestNeedsSync(t *testing.T) {
tests := []struct {
source runtime.Object
target runtime.Object
targetAnnotations map[string]string
name string
want bool
wantError bool
}{
{
name: "needs sync when generation changed",
source: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"generation": int64(5),
},
"spec": map[string]interface{}{
"field": "value",
},
},
},
target: &unstructured.Unstructured{},
targetAnnotations: map[string]string{
"source-generation": "3",
"source-content-hash": "abc123",
},
want: true,
wantError: false,
},
{
name: "doesn't need sync when generation same and hash same",
source: &corev1.Secret{
Data: map[string][]byte{"key": []byte("value")},
},
target: &corev1.Secret{},
targetAnnotations: map[string]string{
"source-generation": "0",
"source-content-hash": mustComputeHash(t, &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}),
},
want: false,
wantError: false,
},
{
name: "needs sync when content hash changed",
source: &corev1.ConfigMap{
Data: map[string]string{"key": "newvalue"},
},
target: &corev1.ConfigMap{},
targetAnnotations: map[string]string{
"source-content-hash": "oldhash",
},
want: true,
wantError: false,
},
{
name: "needs sync when no previous hash",
source: &corev1.Secret{
Data: map[string][]byte{"key": []byte("value")},
},
target: &corev1.Secret{},
targetAnnotations: map[string]string{},
want: true,
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NeedsSync(tt.source, tt.target, tt.targetAnnotations)
if tt.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestGetGeneration(t *testing.T) {
tests := []struct {
obj runtime.Object
name string
want int64
}{
{
name: "returns generation for resource with generation",
obj: &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"generation": int64(42),
},
},
},
want: 42,
},
{
name: "returns 0 for resource without generation",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
},
want: 0,
},
{
name: "returns 0 for nil metadata",
obj: &unstructured.Unstructured{
Object: map[string]interface{}{},
},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getGeneration(tt.obj)
assert.Equal(t, tt.want, got)
})
}
}
// Helper function to compute hash for test setup
func mustComputeHash(t *testing.T, obj runtime.Object) string {
t.Helper()
hash, err := ComputeContentHash(obj)
require.NoError(t, err)
return hash
}
// Benchmark tests
func BenchmarkComputeContentHash_Secret(b *testing.B) {
secret := &corev1.Secret{
Data: map[string][]byte{
"password": []byte("secret123"),
"username": []byte("admin"),
},
Type: corev1.SecretTypeOpaque,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ComputeContentHash(secret)
}
}
func BenchmarkComputeContentHash_ConfigMap(b *testing.B) {
cm := &corev1.ConfigMap{
Data: map[string]string{
"config.yaml": "setting: value\nother: data",
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = ComputeContentHash(cm)
}
}
func BenchmarkNeedsSync(b *testing.B) {
source := &corev1.Secret{
Data: map[string][]byte{"key": []byte("value")},
}
target := &corev1.Secret{}
hash, _ := ComputeContentHash(source)
annotations := map[string]string{
"source-content-hash": hash,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = NeedsSync(source, target, annotations)
}
}