mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-05 22:43:51 +00:00
initial commit
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache: true
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-helm-chart:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get release version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
|
||||
VERSION=${VERSION#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Trigger helm-charts release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
RELEASE_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
gh api repos/lukaszraczylo/helm-charts/dispatches -f event_type=release-chart -f client_payload[chart_name]=kubemirror -f client_payload[version]="$RELEASE_VERSION" -f client_payload[source_repo]=lukaszraczylo/kubemirror -f client_payload[chart_path]=charts/kubemirror
|
||||
+49
@@ -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
|
||||
@@ -0,0 +1,176 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
|
||||
version: 2
|
||||
|
||||
project_name: kubemirror
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go mod verify
|
||||
# Note: Helm chart versioning is handled by the helm-charts repository
|
||||
# triggered by the publish-helm-chart job in .github/workflows/release.yaml
|
||||
|
||||
builds:
|
||||
- id: kubemirror
|
||||
main: ./cmd/kubemirror
|
||||
binary: kubemirror
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.Version={{.Version}}
|
||||
- -X main.Commit={{.Commit}}
|
||||
- -X main.Date={{.Date}}
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
formats:
|
||||
- tar.gz
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
{{- .Version }}_
|
||||
{{- .Os }}_
|
||||
{{- .Arch }}
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
- examples/*
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
algorithm: sha256
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
use: github
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
- '^chore:'
|
||||
- '^ci:'
|
||||
- Merge pull request
|
||||
- Merge branch
|
||||
groups:
|
||||
- title: Features
|
||||
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
|
||||
order: 0
|
||||
- title: Bug Fixes
|
||||
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
|
||||
order: 1
|
||||
- title: Performance Improvements
|
||||
regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$'
|
||||
order: 2
|
||||
- title: Others
|
||||
order: 999
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: lukaszraczylo
|
||||
name: kubemirror
|
||||
draft: false
|
||||
prerelease: auto
|
||||
name_template: "v{{.Version}}"
|
||||
header: |
|
||||
## KubeMirror v{{.Version}}
|
||||
|
||||
Kubernetes controller for mirroring resources (Secrets, ConfigMaps) across namespaces with automatic synchronization.
|
||||
|
||||
### Installation
|
||||
|
||||
**Helm (recommended):**
|
||||
```bash
|
||||
helm repo add kubemirror https://lukaszraczylo.github.io/helm-charts
|
||||
helm repo update
|
||||
helm install kubemirror kubemirror/kubemirror --version {{.Version}}
|
||||
```
|
||||
|
||||
**Helm (from release asset):**
|
||||
```bash
|
||||
helm install kubemirror https://github.com/lukaszraczylo/kubemirror/releases/download/v{{.Version}}/kubemirror-{{.Version}}.tgz
|
||||
```
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
docker pull ghcr.io/lukaszraczylo/kubemirror:{{.Version}}
|
||||
```
|
||||
|
||||
**Binary (Linux/macOS/Windows):**
|
||||
Download the archive for your platform from the assets below, extract, and run:
|
||||
```bash
|
||||
./kubemirror --help
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
Apply labels and annotations to resources you want to mirror:
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: my-secret
|
||||
namespace: default
|
||||
labels:
|
||||
kubemirror.raczylo.com/enabled: "true"
|
||||
annotations:
|
||||
kubemirror.raczylo.com/sync: "true"
|
||||
kubemirror.raczylo.com/target-namespaces: "app1,app2,app-*"
|
||||
```
|
||||
|
||||
See [examples/](https://github.com/lukaszraczylo/kubemirror/tree/main/examples) for more usage patterns.
|
||||
|
||||
dockers_v2:
|
||||
- ids:
|
||||
- kubemirror
|
||||
images:
|
||||
- "ghcr.io/lukaszraczylo/kubemirror"
|
||||
tags:
|
||||
- "{{ .Version }}"
|
||||
- "latest"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
dockerfile: Dockerfile.goreleaser
|
||||
labels:
|
||||
"org.opencontainers.image.title": "{{ .ProjectName }}"
|
||||
"org.opencontainers.image.version": "{{ .Version }}"
|
||||
"org.opencontainers.image.source": "https://github.com/lukaszraczylo/kubemirror"
|
||||
"org.opencontainers.image.description": "Kubernetes controller for mirroring resources across namespaces"
|
||||
"org.opencontainers.image.licenses": "MIT"
|
||||
|
||||
signs:
|
||||
- cmd: cosign
|
||||
signature: "${artifact}.sigstore.json"
|
||||
args:
|
||||
- sign-blob
|
||||
- "--bundle=${signature}"
|
||||
- "${artifact}"
|
||||
- "--yes"
|
||||
artifacts: checksum
|
||||
output: true
|
||||
|
||||
docker_signs:
|
||||
- cmd: cosign
|
||||
artifacts: images
|
||||
output: true
|
||||
args:
|
||||
- sign
|
||||
- "${artifact}@${digest}"
|
||||
- "--yes"
|
||||
+44
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Image URL to use all building/pushing image targets
|
||||
IMG ?= ghcr.io/lukaszraczylo/kubemirror:latest
|
||||
IMG_SECONDARY_TAG ?= ""
|
||||
|
||||
# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
|
||||
ifeq (,$(shell go env GOBIN))
|
||||
GOBIN=$(shell go env GOPATH)/bin
|
||||
else
|
||||
GOBIN=$(shell go env GOBIN)
|
||||
endif
|
||||
|
||||
# Setting SHELL to bash allows bash commands to be executed by recipes.
|
||||
# Options are set to exit when a recipe line exits non-zero or a piped command fails.
|
||||
SHELL = /usr/bin/env bash -o pipefail
|
||||
.SHELLFLAGS = -ec
|
||||
|
||||
.PHONY: all
|
||||
all: build
|
||||
|
||||
##@ General
|
||||
|
||||
.PHONY: help
|
||||
help: ## Display this help.
|
||||
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
|
||||
|
||||
##@ Development
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ## Run go fmt against code.
|
||||
go fmt ./...
|
||||
|
||||
.PHONY: vet
|
||||
vet: ## Run go vet against code.
|
||||
go vet ./...
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Run staticcheck, gosec, and other linters.
|
||||
@command -v staticcheck >/dev/null 2>&1 || { echo "Installing staticcheck..."; go install honnef.co/go/tools/cmd/staticcheck@latest; }
|
||||
@command -v gosec >/dev/null 2>&1 || { echo "Installing gosec..."; go install github.com/securego/gosec/v2/cmd/gosec@latest; }
|
||||
@command -v deadcode >/dev/null 2>&1 || { echo "Installing deadcode..."; go install golang.org/x/tools/cmd/deadcode@latest; }
|
||||
staticcheck ./...
|
||||
gosec -exclude=G115 ./...
|
||||
deadcode ./...
|
||||
|
||||
.PHONY: test
|
||||
test: fmt vet ## Run tests.
|
||||
go test ./... -coverprofile cover.out
|
||||
|
||||
.PHONY: test-race
|
||||
test-race: fmt vet ## Run tests with race detector.
|
||||
go test -race ./...
|
||||
|
||||
.PHONY: test-verbose
|
||||
test-verbose: fmt vet ## Run tests with verbose output.
|
||||
go test -v -race ./...
|
||||
|
||||
.PHONY: bench
|
||||
bench: ## Run benchmarks.
|
||||
go test -race -bench=. -benchmem ./...
|
||||
|
||||
.PHONY: cover
|
||||
cover: test ## Run tests and open coverage in browser.
|
||||
go tool cover -html=cover.out
|
||||
|
||||
##@ Build
|
||||
|
||||
.PHONY: build
|
||||
build: fmt vet ## Build controller binary.
|
||||
go build -o kubemirror ./cmd/kubemirror
|
||||
|
||||
.PHONY: run
|
||||
run: fmt vet ## Run controller from your host (against current kubeconfig).
|
||||
go run ./cmd/kubemirror --dry-run=true
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## Clean build artifacts.
|
||||
rm -f kubemirror cover.out
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: docker-build
|
||||
docker-build: test ## Build docker image.
|
||||
docker build -t ${IMG} .
|
||||
|
||||
.PHONY: docker-push
|
||||
docker-push: ## Push docker image.
|
||||
docker push ${IMG}
|
||||
|
||||
# PLATFORMS defines the target platforms for the manager image
|
||||
PLATFORMS ?= linux/arm64,linux/amd64
|
||||
.PHONY: docker-buildx
|
||||
docker-buildx: ## Build and push docker image for cross-platform support
|
||||
- docker buildx create --name kubemirror-builder
|
||||
docker buildx use kubemirror-builder
|
||||
if [ -z "$(IMG_SECONDARY_TAG)" ]; then \
|
||||
docker buildx build --push --platform=$(PLATFORMS) --tag ${IMG} .; \
|
||||
else \
|
||||
docker buildx build --push --platform=$(PLATFORMS) --tag ${IMG} --tag ${IMG_SECONDARY_TAG} .; \
|
||||
fi
|
||||
- docker buildx rm kubemirror-builder
|
||||
|
||||
##@ Deployment
|
||||
|
||||
ifndef ignore-not-found
|
||||
ignore-not-found = false
|
||||
endif
|
||||
|
||||
.PHONY: deploy
|
||||
deploy: ## Deploy controller to the K8s cluster specified in ~/.kube/config.
|
||||
kubectl apply -k deploy/
|
||||
|
||||
.PHONY: undeploy
|
||||
undeploy: ## Remove controller from the K8s cluster.
|
||||
kubectl delete -k deploy/ --ignore-not-found=$(ignore-not-found)
|
||||
|
||||
.PHONY: logs
|
||||
logs: ## Show controller logs.
|
||||
kubectl -n kubemirror-system logs -l app.kubernetes.io/name=kubemirror -f
|
||||
|
||||
.PHONY: status
|
||||
status: ## Show controller status.
|
||||
kubectl -n kubemirror-system get pods -l app.kubernetes.io/name=kubemirror
|
||||
kubectl -n kubemirror-system get deployments
|
||||
kubectl -n kubemirror-system get services
|
||||
|
||||
##@ Code Quality
|
||||
|
||||
.PHONY: tidy
|
||||
tidy: ## Run go mod tidy.
|
||||
go mod tidy
|
||||
|
||||
.PHONY: verify
|
||||
verify: fmt vet lint test-race ## Run all verification steps (format, vet, lint, test with race).
|
||||
|
||||
.PHONY: ci
|
||||
ci: verify bench ## Run full CI pipeline locally.
|
||||
|
||||
##@ Release
|
||||
|
||||
.PHONY: release-dry
|
||||
release-dry: ## Run GoReleaser in dry-run mode.
|
||||
@command -v goreleaser >/dev/null 2>&1 || { echo "Installing goreleaser..."; go install github.com/goreleaser/goreleaser@latest; }
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
.PHONY: release
|
||||
release: ## Run GoReleaser (requires tag).
|
||||
@command -v goreleaser >/dev/null 2>&1 || { echo "Installing goreleaser..."; go install github.com/goreleaser/goreleaser@latest; }
|
||||
goreleaser release --clean
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: kubemirror-system
|
||||
labels:
|
||||
app.kubernetes.io/name: kubemirror
|
||||
app.kubernetes.io/component: system
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: ""})
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user