From 8adb52608fce42b12f7c63889d80a50b78d4c849 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 25 Dec 2025 22:10:57 +0000 Subject: [PATCH] initial commit --- .github/workflows/ci.yaml | 107 ++++ .github/workflows/release.yaml | 68 +++ .gitignore | 49 ++ .goreleaser.yaml | 176 ++++++ Dockerfile | 44 ++ Dockerfile.goreleaser | 12 + LICENSE | 21 + Makefile | 147 +++++ README.md | 373 +++++++++++++ deploy/deployment.yaml | 84 +++ deploy/kustomization.yaml | 19 + deploy/namespace.yaml | 8 + deploy/rbac.yaml | 86 +++ deploy/service.yaml | 23 + examples/README.md | 250 +++++++++ examples/configmap-all.yaml | 22 + examples/kustomization.yaml | 13 + examples/namespaces.yaml | 49 ++ examples/secret-basic.yaml | 19 + examples/secret-pattern.yaml | 17 + examples/source-configmap.yaml | 79 +++ examples/source-secret.yaml | 56 ++ examples/traefik-middleware.yaml | 64 +++ go.mod | 70 +++ go.sum | 181 ++++++ monitoring/README.md | 267 +++++++++ monitoring/grafana-dashboard.json | 678 +++++++++++++++++++++++ monitoring/prometheusrule.yaml | 146 +++++ monitoring/servicemonitor.yaml | 30 + pkg/config/config.go | 77 +++ pkg/config/resource_types.go | 98 ++++ pkg/config/resource_types_test.go | 225 ++++++++ pkg/constants/constants.go | 132 +++++ pkg/controller/mirror.go | 277 +++++++++ pkg/controller/mirror_test.go | 622 +++++++++++++++++++++ pkg/controller/namespace_lister.go | 57 ++ pkg/controller/source_reconciler.go | 446 +++++++++++++++ pkg/controller/source_reconciler_test.go | 463 ++++++++++++++++ pkg/discovery/discovery.go | 153 +++++ pkg/discovery/discovery_test.go | 88 +++ pkg/discovery/manager.go | 191 +++++++ pkg/discovery/manager_test.go | 154 +++++ pkg/filter/namespace.go | 169 ++++++ pkg/filter/namespace_test.go | 587 ++++++++++++++++++++ pkg/hash/content.go | 144 +++++ pkg/hash/content_test.go | 529 ++++++++++++++++++ 46 files changed, 7570 insertions(+) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 Dockerfile create mode 100644 Dockerfile.goreleaser create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 deploy/deployment.yaml create mode 100644 deploy/kustomization.yaml create mode 100644 deploy/namespace.yaml create mode 100644 deploy/rbac.yaml create mode 100644 deploy/service.yaml create mode 100644 examples/README.md create mode 100644 examples/configmap-all.yaml create mode 100644 examples/kustomization.yaml create mode 100644 examples/namespaces.yaml create mode 100644 examples/secret-basic.yaml create mode 100644 examples/secret-pattern.yaml create mode 100644 examples/source-configmap.yaml create mode 100644 examples/source-secret.yaml create mode 100644 examples/traefik-middleware.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 monitoring/README.md create mode 100644 monitoring/grafana-dashboard.json create mode 100644 monitoring/prometheusrule.yaml create mode 100644 monitoring/servicemonitor.yaml create mode 100644 pkg/config/config.go create mode 100644 pkg/config/resource_types.go create mode 100644 pkg/config/resource_types_test.go create mode 100644 pkg/constants/constants.go create mode 100644 pkg/controller/mirror.go create mode 100644 pkg/controller/mirror_test.go create mode 100644 pkg/controller/namespace_lister.go create mode 100644 pkg/controller/source_reconciler.go create mode 100644 pkg/controller/source_reconciler_test.go create mode 100644 pkg/discovery/discovery.go create mode 100644 pkg/discovery/discovery_test.go create mode 100644 pkg/discovery/manager.go create mode 100644 pkg/discovery/manager_test.go create mode 100644 pkg/filter/namespace.go create mode 100644 pkg/filter/namespace_test.go create mode 100644 pkg/hash/content.go create mode 100644 pkg/hash/content_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..5fdccf9 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..eec5e15 --- /dev/null +++ b/.github/workflows/release.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d25098 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..cad835f --- /dev/null +++ b/.goreleaser.yaml @@ -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" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cfda924 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser new file mode 100644 index 0000000..da888d8 --- /dev/null +++ b/Dockerfile.goreleaser @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..887ccb3 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5c2d089 --- /dev/null +++ b/Makefile @@ -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\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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc87a05 --- /dev/null +++ b/README.md @@ -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. diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..ecc203a --- /dev/null +++ b/deploy/deployment.yaml @@ -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 diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml new file mode 100644 index 0000000..196a10e --- /dev/null +++ b/deploy/kustomization.yaml @@ -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 diff --git a/deploy/namespace.yaml b/deploy/namespace.yaml new file mode 100644 index 0000000..07887e6 --- /dev/null +++ b/deploy/namespace.yaml @@ -0,0 +1,8 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: kubemirror-system + labels: + app.kubernetes.io/name: kubemirror + app.kubernetes.io/component: system diff --git a/deploy/rbac.yaml b/deploy/rbac.yaml new file mode 100644 index 0000000..59b09a0 --- /dev/null +++ b/deploy/rbac.yaml @@ -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 diff --git a/deploy/service.yaml b/deploy/service.yaml new file mode 100644 index 0000000..4d2d93d --- /dev/null +++ b/deploy/service.yaml @@ -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 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..94e79ff --- /dev/null +++ b/examples/README.md @@ -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" +``` diff --git a/examples/configmap-all.yaml b/examples/configmap-all.yaml new file mode 100644 index 0000000..889613c --- /dev/null +++ b/examples/configmap-all.yaml @@ -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" diff --git a/examples/kustomization.yaml b/examples/kustomization.yaml new file mode 100644 index 0000000..f985641 --- /dev/null +++ b/examples/kustomization.yaml @@ -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 diff --git a/examples/namespaces.yaml b/examples/namespaces.yaml new file mode 100644 index 0000000..809f33a --- /dev/null +++ b/examples/namespaces.yaml @@ -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 diff --git a/examples/secret-basic.yaml b/examples/secret-basic.yaml new file mode 100644 index 0000000..db73c7b --- /dev/null +++ b/examples/secret-basic.yaml @@ -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 diff --git a/examples/secret-pattern.yaml b/examples/secret-pattern.yaml new file mode 100644 index 0000000..4047e35 --- /dev/null +++ b/examples/secret-pattern.yaml @@ -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 diff --git a/examples/source-configmap.yaml b/examples/source-configmap.yaml new file mode 100644 index 0000000..0183025 --- /dev/null +++ b/examples/source-configmap.yaml @@ -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; + } diff --git a/examples/source-secret.yaml b/examples/source-secret.yaml new file mode 100644 index 0000000..ead1371 --- /dev/null +++ b/examples/source-secret.yaml @@ -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" diff --git a/examples/traefik-middleware.yaml b/examples/traefik-middleware.yaml new file mode 100644 index 0000000..1ef190e --- /dev/null +++ b/examples/traefik-middleware.yaml @@ -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" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2a0428e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f80a9ef --- /dev/null +++ b/go.sum @@ -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= diff --git a/monitoring/README.md b/monitoring/README.md new file mode 100644 index 0000000..1a11432 --- /dev/null +++ b/monitoring/README.md @@ -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) diff --git a/monitoring/grafana-dashboard.json b/monitoring/grafana-dashboard.json new file mode 100644 index 0000000..a495a54 --- /dev/null +++ b/monitoring/grafana-dashboard.json @@ -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 +} diff --git a/monitoring/prometheusrule.yaml b/monitoring/prometheusrule.yaml new file mode 100644 index 0000000..d96e840 --- /dev/null +++ b/monitoring/prometheusrule.yaml @@ -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) diff --git a/monitoring/servicemonitor.yaml b/monitoring/servicemonitor.yaml new file mode 100644 index 0000000..93a25ab --- /dev/null +++ b/monitoring/servicemonitor.yaml @@ -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 diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..a38805a --- /dev/null +++ b/pkg/config/config.go @@ -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 +} diff --git a/pkg/config/resource_types.go b/pkg/config/resource_types.go new file mode 100644 index 0000000..b252326 --- /dev/null +++ b/pkg/config/resource_types.go @@ -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 +} diff --git a/pkg/config/resource_types_test.go b/pkg/config/resource_types_test.go new file mode 100644 index 0000000..a220874 --- /dev/null +++ b/pkg/config/resource_types_test.go @@ -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: ""}) +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go new file mode 100644 index 0000000..0a6fcfb --- /dev/null +++ b/pkg/constants/constants.go @@ -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", + } +) diff --git a/pkg/controller/mirror.go b/pkg/controller/mirror.go new file mode 100644 index 0000000..0513e47 --- /dev/null +++ b/pkg/controller/mirror.go @@ -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 +} diff --git a/pkg/controller/mirror_test.go b/pkg/controller/mirror_test.go new file mode 100644 index 0000000..e103a9f --- /dev/null +++ b/pkg/controller/mirror_test.go @@ -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) + } +} diff --git a/pkg/controller/namespace_lister.go b/pkg/controller/namespace_lister.go new file mode 100644 index 0000000..ccfea1b --- /dev/null +++ b/pkg/controller/namespace_lister.go @@ -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 +} diff --git a/pkg/controller/source_reconciler.go b/pkg/controller/source_reconciler.go new file mode 100644 index 0000000..9e739a3 --- /dev/null +++ b/pkg/controller/source_reconciler.go @@ -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, + }, + }, + } +} diff --git a/pkg/controller/source_reconciler_test.go b/pkg/controller/source_reconciler_test.go new file mode 100644 index 0000000..242ea23 --- /dev/null +++ b/pkg/controller/source_reconciler_test.go @@ -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) + } +} diff --git a/pkg/discovery/discovery.go b/pkg/discovery/discovery.go new file mode 100644 index 0000000..06e3ad7 --- /dev/null +++ b/pkg/discovery/discovery.go @@ -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] +} diff --git a/pkg/discovery/discovery_test.go b/pkg/discovery/discovery_test.go new file mode 100644 index 0000000..c38cac2 --- /dev/null +++ b/pkg/discovery/discovery_test.go @@ -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) + }) + } +} diff --git a/pkg/discovery/manager.go b/pkg/discovery/manager.go new file mode 100644 index 0000000..410a61b --- /dev/null +++ b/pkg/discovery/manager.go @@ -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 + } + } + } +} diff --git a/pkg/discovery/manager_test.go b/pkg/discovery/manager_test.go new file mode 100644 index 0000000..2f65971 --- /dev/null +++ b/pkg/discovery/manager_test.go @@ -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) +} diff --git a/pkg/filter/namespace.go b/pkg/filter/namespace.go new file mode 100644 index 0000000..da39ea0 --- /dev/null +++ b/pkg/filter/namespace.go @@ -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 +} diff --git a/pkg/filter/namespace_test.go b/pkg/filter/namespace_test.go new file mode 100644 index 0000000..c317f76 --- /dev/null +++ b/pkg/filter/namespace_test.go @@ -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, + ) + } + }) +} diff --git a/pkg/hash/content.go b/pkg/hash/content.go new file mode 100644 index 0000000..482285b --- /dev/null +++ b/pkg/hash/content.go @@ -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() +} diff --git a/pkg/hash/content_test.go b/pkg/hash/content_test.go new file mode 100644 index 0000000..ac69429 --- /dev/null +++ b/pkg/hash/content_test.go @@ -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) + } +}