commit 8adb52608fce42b12f7c63889d80a50b78d4c849 Author: Lukasz Raczylo Date: Thu Dec 25 22:10:57 2025 +0000 initial commit 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) + } +}