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