├── .dockerignore ├── .github ├── dependabot.yaml └── workflows │ ├── dependabot.auto.yaml │ ├── pipeline.yaml │ └── stale.yaml ├── .gitignore ├── Directory.Build.props ├── Directory.Packages.props ├── ES.Kubernetes.Reflector.sln ├── ES.Kubernetes.Reflector.sln.DotSettings ├── GitVersion.yaml ├── LICENSE ├── NuGet.config ├── README.md ├── Shared.DotSettings ├── assets └── helm_icon.png ├── src ├── ES.Kubernetes.Reflector │ ├── Configuration │ │ ├── ReflectorOptions.cs │ │ └── WatcherOptions.cs │ ├── Dockerfile │ ├── ES.Kubernetes.Reflector.csproj │ ├── Mirroring │ │ ├── ConfigMapMirror.cs │ │ ├── Core │ │ │ ├── Annotations.cs │ │ │ ├── MirroringProperties.cs │ │ │ ├── MirroringPropertiesExtensions.cs │ │ │ └── ResourceMirror.cs │ │ └── SecretMirror.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Watchers │ │ ├── ConfigMapWatcher.cs │ │ ├── Core │ │ │ ├── Events │ │ │ │ ├── IWatcherClosedHandler.cs │ │ │ │ ├── IWatcherEventHandler.cs │ │ │ │ ├── WatcherClosed.cs │ │ │ │ └── WatcherEvent.cs │ │ │ └── WatcherBackgroundService.cs │ │ ├── NamespaceWatcher.cs │ │ └── SecretWatcher.cs │ ├── appsettings.Development.json │ └── appsettings.json └── helm │ └── reflector │ ├── .helmignore │ ├── Chart.yaml │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── clusterRole.yaml │ ├── clusterRoleBinding.yaml │ ├── cron.yaml │ ├── deployment.yaml │ ├── hpa.yaml │ └── serviceaccount.yaml │ └── values.yaml └── tests └── ES.Kubernetes.Reflector.Tests ├── Additions └── ReflectorAnnotationsBuilder.cs ├── ES.Kubernetes.Reflector.Tests.csproj ├── Fixtures ├── KubernetesFixture.cs └── ReflectorFixture.cs └── Integration ├── Base └── BaseIntegrationTest.cs ├── Fixtures └── ReflectorIntegrationFixture.cs ├── HealthCheckIntegrationTests.cs └── MirroringIntegrationTests.cs /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | registries: 3 | public-nuget: 4 | type: nuget-feed 5 | url: https://api.nuget.org/v3/index.json 6 | updates: 7 | - package-ecosystem: nuget 8 | directory: "/" 9 | registries: 10 | - public-nuget 11 | schedule: 12 | interval: daily 13 | open-pull-requests-limit: 15 14 | labels: 15 | - "area-dependencies" 16 | groups: 17 | all-dependencies: 18 | patterns: 19 | - "*" 20 | - package-ecosystem: "github-actions" 21 | directory: "/" 22 | schedule: 23 | interval: daily 24 | open-pull-requests-limit: 5 25 | labels: 26 | - "area-dependencies" 27 | groups: 28 | all-dependencies: 29 | patterns: 30 | - "*" -------------------------------------------------------------------------------- /.github/workflows/dependabot.auto.yaml: -------------------------------------------------------------------------------- 1 | name: Dependabot AutoMerge 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | dependabot: 14 | runs-on: ubuntu-latest 15 | if: github.event.pull_request.user.login == 'dependabot[bot]' 16 | steps: 17 | - name: Fetch Dependabot metadata 18 | id: metadata 19 | uses: dependabot/fetch-metadata@v2 20 | with: 21 | github-token: "${{ secrets.GITHUB_TOKEN }}" 22 | skip-commit-verification: true 23 | skip-verification: true 24 | 25 | - name: Enable auto-merge 26 | run: gh pr merge --auto --squash "$PR_URL" 27 | env: 28 | PR_URL: ${{ github.event.pull_request.html_url }} 29 | GH_TOKEN: ${{ secrets.ES_GITHUB_PAT }} 30 | 31 | - name: Approve the PR 32 | run: gh pr review --approve "$PR_URL" 33 | env: 34 | PR_URL: ${{ github.event.pull_request.html_url }} 35 | GH_TOKEN: ${{ secrets.ES_GITHUB_PAT }} 36 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yaml: -------------------------------------------------------------------------------- 1 | name: Pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" # Matches all branches 7 | pull_request: 8 | branches: 9 | - "**" # Matches all branches 10 | 11 | workflow_dispatch: 12 | inputs: 13 | force_build: 14 | description: "Forces a build even if no changes are detected" 15 | required: true 16 | default: "false" 17 | force_release: 18 | description: "Forces a release even if no changes are detected" 19 | required: true 20 | default: "false" 21 | 22 | concurrency: 23 | group: pipeline-${{ github.ref_name }} 24 | cancel-in-progress: true 25 | 26 | env: 27 | helm_chart: "reflector" 28 | helm_chart_dir: "src/helm/reflector" 29 | helm_chart_repository: "ghcr.io/emberstack/helm-charts" 30 | helm_chart_repository_protocol: "oci://" 31 | 32 | container_image: "kubernetes-reflector" 33 | container_image_build_context: "." 34 | container_image_build_platforms: "linux/amd64,linux/arm/v7,linux/arm64" 35 | container_image_build_dockerfile: "src/ES.Kubernetes.Reflector/Dockerfile" 36 | container_image_repository_dockerhub: "emberstack" 37 | container_image_repository_ghcr: "ghcr.io/emberstack" 38 | 39 | jobs: 40 | discovery: 41 | runs-on: ubuntu-latest 42 | permissions: 43 | contents: read 44 | pull-requests: read 45 | outputs: 46 | pathsFilter_src: ${{ steps.pathsFilter.outputs.src }} 47 | gitVersion_SemVer: ${{ steps.gitversion.outputs.GitVersion_SemVer }} 48 | gitVersion_AssemblySemFileVer: ${{ steps.gitversion.outputs.GitVersion_AssemblySemFileVer }} 49 | build: ${{ steps.evaluate_build.outputs.result }} 50 | build_push: ${{ steps.evaluate_build_push.outputs.result }} 51 | build_configuration: ${{ steps.evaluate_build_configuration.outputs.result }} 52 | release: ${{ steps.evaluate_release.outputs.result }} 53 | steps: 54 | - name: checkout 55 | uses: actions/checkout@v4 56 | with: 57 | fetch-depth: 0 58 | 59 | - name: tools - dotnet - install 60 | uses: actions/setup-dotnet@v4 61 | with: 62 | dotnet-version: "9.x" 63 | 64 | - name: tools - gitversion - install 65 | uses: gittools/actions/gitversion/setup@v3.2.1 66 | with: 67 | versionSpec: "5.x" 68 | preferLatestVersion: true 69 | 70 | - name: gitversion - execute 71 | id: gitversion 72 | uses: gittools/actions/gitversion/execute@v3.2.1 73 | with: 74 | useConfigFile: true 75 | configFilePath: GitVersion.yaml 76 | 77 | - name: tools - detect changes 78 | id: pathsFilter 79 | uses: dorny/paths-filter@v3 80 | with: 81 | base: ${{ github.ref }} 82 | filters: | 83 | src: 84 | - '*.sln' 85 | - '*.slnx' 86 | - '*.props' 87 | - 'src/**' 88 | build: 89 | - '*.sln' 90 | - '*.slnx' 91 | - '*.props' 92 | - 'src/**' 93 | - 'tests/**' 94 | - 'playground/**' 95 | 96 | - name: evaluate - build 97 | id: evaluate_build 98 | env: 99 | RESULT: ${{ steps.pathsFilter.outputs.build == 'true' || github.event.inputs.force_build == 'true' || github.event.inputs.force_release == 'true' }} 100 | run: echo "result=$RESULT" >> $GITHUB_OUTPUT 101 | 102 | - name: evaluate - build_push 103 | id: evaluate_build_push 104 | env: 105 | RESULT: ${{ github.actor != 'dependabot[bot]' && github.event_name != 'pull_request' && (steps.pathsFilter.outputs.src == 'true' || github.event.inputs.force_build == 'true') }} 106 | run: echo "result=$RESULT" >> $GITHUB_OUTPUT 107 | 108 | - name: evaluate - build_configuration 109 | id: evaluate_build_configuration 110 | env: 111 | RESULT: ${{ github.ref == 'refs/heads/main' && 'Release' || 'Debug' }} 112 | run: echo "result=$RESULT" >> $GITHUB_OUTPUT 113 | 114 | - name: evaluate - release 115 | id: evaluate_release 116 | env: 117 | RESULT: ${{ github.ref == 'refs/heads/main' || github.event.inputs.force_release == 'true' }} 118 | run: echo "result=$RESULT" >> $GITHUB_OUTPUT 119 | 120 | 121 | build: 122 | name: build 123 | if: ${{ needs.discovery.outputs.build == 'true' }} 124 | needs: [discovery] 125 | runs-on: ubuntu-latest 126 | env: 127 | build: ${{ needs.discovery.outputs.build }} 128 | build_push: ${{ needs.discovery.outputs.build_push }} 129 | build_configuration: ${{ needs.discovery.outputs.build_configuration }} 130 | gitVersion_SemVer: ${{ needs.discovery.outputs.gitVersion_SemVer }} 131 | gitVersion_AssemblySemFileVer: ${{ needs.discovery.outputs.gitVersion_AssemblySemFileVer }} 132 | steps: 133 | - name: checkout 134 | uses: actions/checkout@v4 135 | 136 | - name: artifacts - prepare directories 137 | run: | 138 | mkdir -p .artifacts/helm 139 | mkdir -p .artifacts/kubectl 140 | 141 | - name: tools - dotnet - install 142 | uses: actions/setup-dotnet@v4 143 | with: 144 | dotnet-version: "9.x" 145 | 146 | - name: dotnet - restore 147 | run: dotnet restore 148 | 149 | - name: dotnet - build 150 | run: dotnet build --no-restore --configuration ${{ env.build_configuration }} /p:Version=${{ env.gitVersion_SemVer }} /p:AssemblyVersion=${{env.gitVersion_AssemblySemFileVer}} /p:NuGetVersion=${{env.gitVersion_SemVer}} 151 | 152 | - name: dotnet - test 153 | run: dotnet test --no-build --configuration ${{ env.build_configuration }} --verbosity normal 154 | 155 | - name: tests - report 156 | uses: dorny/test-reporter@v2 157 | if: ${{ github.event.pull_request.head.repo.fork == false }} 158 | with: 159 | name: Test Results 160 | path: .artifacts/TestResults/*.trx 161 | reporter: dotnet-trx 162 | fail-on-empty: "false" 163 | 164 | - name: tools - helm - install 165 | uses: azure/setup-helm@v4 166 | 167 | - name: tools - helm - login - ghcr.io 168 | if: ${{ env.build_push == 'true' }} 169 | run: echo "${{ secrets.ES_GITHUB_PAT }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin 170 | 171 | - name: tools - docker - login ghcr.io 172 | if: ${{ env.build_push == 'true' }} 173 | uses: docker/login-action@v3 174 | with: 175 | registry: ghcr.io 176 | username: ${{ github.actor }} 177 | password: ${{ secrets.ES_GITHUB_PAT }} 178 | 179 | - name: tools - docker - login docker.io 180 | if: ${{ env.build_push == 'true' }} 181 | uses: docker/login-action@v3 182 | with: 183 | registry: docker.io 184 | username: ${{ secrets.ES_DOCKERHUB_USERNAME }} 185 | password: ${{ secrets.ES_DOCKERHUB_PAT }} 186 | 187 | - name: tools - docker - register QEMU 188 | run: | 189 | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes 190 | 191 | - name: tools - docker - setup buildx 192 | uses: docker/setup-buildx-action@v3 193 | with: 194 | driver: docker-container # REQUIRED for multi-platform builds 195 | 196 | - name: helm - import README 197 | run: cp README.md ${{ env.helm_chart_dir }}/README.md 198 | 199 | - name: helm - package chart 200 | run: helm package --destination .artifacts/helm --version ${{ env.gitVersion_SemVer }} --app-version ${{ env.gitVersion_SemVer }} ${{ env.helm_chart_dir }} 201 | 202 | - name: helm - template chart 203 | run: helm template --namespace kube-system ${{ env.helm_chart }} .artifacts/helm/${{ env.helm_chart }}-${{ env.gitVersion_SemVer }}.tgz > .artifacts/kubectl/${{ env.helm_chart }}.yaml 204 | 205 | - name: docker - build and push 206 | uses: docker/build-push-action@v6 207 | with: 208 | context: ${{ env.container_image_build_context }} 209 | file: ${{ env.container_image_build_dockerfile }} 210 | build-args: | 211 | BUILD_CONFIGURATION=${{ env.build_configuration }} 212 | push: ${{ env.build_push == 'true' }} 213 | provenance: false 214 | platforms: ${{ env.container_image_build_platforms }} 215 | labels: | 216 | org.opencontainers.image.source=https://github.com/${{ github.repository }} 217 | org.opencontainers.image.url=https://github.com/${{ github.repository }} 218 | org.opencontainers.image.vendor=https://github.com/${{ github.repository_owner }} 219 | org.opencontainers.image.version=${{ env.gitVersion_SemVer }} 220 | org.opencontainers.image.revision=${{ github.sha }} 221 | tags: | 222 | ${{ env.container_image_repository_dockerhub }}/${{ env.container_image }}:${{ env.gitVersion_SemVer }} 223 | ${{ env.container_image_repository_ghcr }}/${{ env.container_image }}:${{ env.gitVersion_SemVer }} 224 | 225 | - name: helm - push 226 | if: ${{ env.build_push == 'true' }} 227 | run: helm push .artifacts/helm/${{ env.helm_chart }}-${{ env.gitVersion_SemVer }}.tgz ${{ env.helm_chart_repository_protocol }}${{ env.helm_chart_repository }} 228 | 229 | - name: artifacts - helm - upload 230 | uses: actions/upload-artifact@v4 231 | with: 232 | name: artifacts-helm-${{env.gitVersion_SemVer}} 233 | path: .artifacts/helm 234 | 235 | - name: artifacts - kubectl - upload 236 | uses: actions/upload-artifact@v4 237 | with: 238 | name: artifacts-kubectl-${{env.gitVersion_SemVer}} 239 | path: .artifacts/kubectl 240 | 241 | release: 242 | name: release 243 | if: ${{ needs.discovery.outputs.release == 'true' && github.ref == 'refs/heads/main' }} 244 | needs: [discovery, build] 245 | runs-on: ubuntu-latest 246 | env: 247 | gitVersion_SemVer: ${{ needs.discovery.outputs.gitVersion_SemVer }} 248 | gitVersion_AssemblySemFileVer: ${{ needs.discovery.outputs.gitVersion_AssemblySemFileVer }} 249 | steps: 250 | 251 | - name: artifacts - helm - download 252 | uses: actions/download-artifact@v4 253 | with: 254 | name: artifacts-helm-${{env.gitVersion_SemVer}} 255 | path: .artifacts/helm 256 | 257 | - name: artifacts - kubectl - download 258 | uses: actions/download-artifact@v4 259 | with: 260 | name: artifacts-kubectl-${{env.gitVersion_SemVer}} 261 | path: .artifacts/kubectl 262 | 263 | - name: tools - helm - install 264 | uses: azure/setup-helm@v4 265 | 266 | - name: tools - helm - login - ghcr.io 267 | run: echo "${{ secrets.ES_GITHUB_PAT }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin 268 | 269 | - name: tools - oras - install 270 | uses: oras-project/setup-oras@v1 271 | 272 | - name: tools - oras - login - ghcr.io 273 | run: echo "${{ secrets.ES_GITHUB_PAT }}" | oras login ghcr.io -u ${{ github.actor }} --password-stdin 274 | 275 | - name: tools - docker - login ghcr.io 276 | uses: docker/login-action@v3 277 | with: 278 | registry: ghcr.io 279 | username: ${{ github.actor }} 280 | password: ${{ secrets.ES_GITHUB_PAT }} 281 | 282 | - name: tools - docker - login docker.io 283 | uses: docker/login-action@v3 284 | with: 285 | registry: docker.io 286 | username: ${{ secrets.ES_DOCKERHUB_USERNAME }} 287 | password: ${{ secrets.ES_DOCKERHUB_PAT }} 288 | 289 | - name: tools - docker - setup buildx 290 | uses: docker/setup-buildx-action@v3 291 | 292 | - name: docker - tag and push - latest 293 | run: | 294 | docker buildx imagetools create \ 295 | --tag ${{ env.container_image_repository_dockerhub }}/${{ env.container_image }}:latest \ 296 | --tag ${{ env.container_image_repository_ghcr }}/${{ env.container_image }}:latest \ 297 | --tag ${{ env.container_image_repository_dockerhub }}/${{ env.container_image }}:${{ env.gitVersion_SemVer }} \ 298 | --tag ${{ env.container_image_repository_ghcr }}/${{ env.container_image }}:${{ env.gitVersion_SemVer }} \ 299 | ${{ env.container_image_repository_ghcr }}/${{ env.container_image }}:${{ env.gitVersion_SemVer }} 300 | 301 | - name: helm - push 302 | run: helm push .artifacts/helm/${{ env.helm_chart }}-${{ env.gitVersion_SemVer }}.tgz ${{ env.helm_chart_repository_protocol }}${{ env.helm_chart_repository }} 303 | 304 | - name: github - release - create 305 | uses: softprops/action-gh-release@v2 306 | with: 307 | repository: ${{ github.repository }} 308 | name: v${{ env.gitVersion_SemVer }} 309 | tag_name: v${{ env.gitVersion_SemVer }} 310 | body: The release process is automated. 311 | generate_release_notes: true 312 | token: ${{ secrets.ES_GITHUB_PAT }} 313 | files: | 314 | .artifacts/kubectl/${{ env.helm_chart }}.yaml 315 | 316 | - name: github - repository-dispatch - release 317 | uses: peter-evans/repository-dispatch@v3 318 | with: 319 | token: ${{ secrets.ES_GITHUB_PAT }} 320 | repository: emberstack/helm-charts 321 | event-type: release 322 | client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}"}' -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: Stale 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' # Runs daily at midnight UTC 6 | workflow_dispatch: # Allows manual triggering 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/stale@v9 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | # General behavior 21 | stale-issue-message: > 22 | Automatically marked as stale due to no recent activity. 23 | It will be closed if no further activity occurs. Thank you for your contributions. 24 | close-issue-message: > 25 | Automatically closed stale item. 26 | stale-pr-message: > 27 | Automatically marked as stale due to no recent activity. 28 | It will be closed if no further activity occurs. Thank you for your contributions. 29 | close-pr-message: > 30 | Automatically closed stale item. 31 | 32 | days-before-stale: 14 33 | days-before-close: 14 34 | 35 | # Labels 36 | stale-issue-label: 'stale' 37 | stale-pr-label: 'stale' 38 | 39 | exempt-issue-labels: 'pinned,security,[Status] Maybe Later' 40 | exempt-pr-labels: 'pinned,security,[Status] Maybe Later' 41 | 42 | # Limits 43 | operations-per-run: 30 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | # **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.svclog 86 | *.scc 87 | 88 | # Chutzpah Test files 89 | _Chutzpah* 90 | 91 | # Visual C++ cache files 92 | ipch/ 93 | *.aps 94 | *.ncb 95 | *.opendb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | *.VC.db 100 | *.VC.VC.opendb 101 | 102 | # Visual Studio profiler 103 | *.psess 104 | *.vsp 105 | *.vspx 106 | *.sap 107 | 108 | # Visual Studio Trace Files 109 | *.e2e 110 | 111 | # TFS 2012 Local Workspace 112 | $tf/ 113 | 114 | # Guidance Automation Toolkit 115 | *.gpState 116 | 117 | # ReSharper is a .NET coding add-in 118 | _ReSharper*/ 119 | *.[Rr]e[Ss]harper 120 | *.DotSettings.user 121 | 122 | # JustCode is a .NET coding add-in 123 | .JustCode 124 | 125 | # TeamCity is a build add-in 126 | _TeamCity* 127 | 128 | # DotCover is a Code Coverage Tool 129 | *.dotCover 130 | 131 | # AxoCover is a Code Coverage Tool 132 | .axoCover/* 133 | !.axoCover/settings.json 134 | 135 | # Visual Studio code coverage results 136 | *.coverage 137 | *.coveragexml 138 | 139 | # NCrunch 140 | _NCrunch_* 141 | .*crunch*.local.xml 142 | nCrunchTemp_* 143 | 144 | # MightyMoose 145 | *.mm.* 146 | AutoTest.Net/ 147 | 148 | # Web workbench (sass) 149 | .sass-cache/ 150 | 151 | # Installshield output folder 152 | [Ee]xpress/ 153 | 154 | # DocProject is a documentation generator add-in 155 | DocProject/buildhelp/ 156 | DocProject/Help/*.HxT 157 | DocProject/Help/*.HxC 158 | DocProject/Help/*.hhc 159 | DocProject/Help/*.hhk 160 | DocProject/Help/*.hhp 161 | DocProject/Help/Html2 162 | DocProject/Help/html 163 | 164 | # Click-Once directory 165 | publish/ 166 | 167 | # Publish Web Output 168 | *.[Pp]ublish.xml 169 | *.azurePubxml 170 | # Note: Comment the next line if you want to checkin your web deploy settings, 171 | # but database connection strings (with potential passwords) will be unencrypted 172 | *.pubxml 173 | *.publishproj 174 | 175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 176 | # checkin your Azure Web App publish settings, but sensitive information contained 177 | # in these scripts will be unencrypted 178 | PublishScripts/ 179 | 180 | # NuGet Packages 181 | *.nupkg 182 | # The packages folder can be ignored because of Package Restore 183 | **/[Pp]ackages/* 184 | # except build/, which is used as an MSBuild target. 185 | !**/[Pp]ackages/build/ 186 | # Uncomment if necessary however generally it will be regenerated when needed 187 | #!**/[Pp]ackages/repositories.config 188 | # NuGet v3's project.json files produces more ignorable files 189 | *.nuget.props 190 | *.nuget.targets 191 | 192 | # Microsoft Azure Build Output 193 | csx/ 194 | *.build.csdef 195 | 196 | # Microsoft Azure Emulator 197 | ecf/ 198 | rcf/ 199 | 200 | # Windows Store app package directories and files 201 | AppPackages/ 202 | BundleArtifacts/ 203 | Package.StoreAssociation.xml 204 | _pkginfo.txt 205 | *.appx 206 | 207 | # Visual Studio cache files 208 | # files ending in .cache can be ignored 209 | *.[Cc]ache 210 | # but keep track of directories ending in .cache 211 | !*.[Cc]ache/ 212 | 213 | # Others 214 | ClientBin/ 215 | ~$* 216 | *~ 217 | *.dbmdl 218 | *.dbproj.schemaview 219 | *.jfm 220 | *.pfx 221 | *.publishsettings 222 | orleans.codegen.cs 223 | 224 | # Including strong name files can present a security risk 225 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 226 | #*.snk 227 | 228 | # Since there are multiple workflows, uncomment next line to ignore bower_components 229 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 230 | #bower_components/ 231 | 232 | # RIA/Silverlight projects 233 | Generated_Code/ 234 | 235 | # Backup & report files from converting an old project file 236 | # to a newer Visual Studio version. Backup files are not needed, 237 | # because we have git ;-) 238 | _UpgradeReport_Files/ 239 | Backup*/ 240 | UpgradeLog*.XML 241 | UpgradeLog*.htm 242 | ServiceFabricBackup/ 243 | *.rptproj.bak 244 | 245 | # SQL Server files 246 | *.mdf 247 | *.ldf 248 | *.ndf 249 | 250 | # Business Intelligence projects 251 | *.rdl.data 252 | *.bim.layout 253 | *.bim_*.settings 254 | *.rptproj.rsuser 255 | 256 | # Microsoft Fakes 257 | FakesAssemblies/ 258 | 259 | # GhostDoc plugin setting file 260 | *.GhostDoc.xml 261 | 262 | # Node.js Tools for Visual Studio 263 | .ntvs_analysis.dat 264 | node_modules/ 265 | 266 | # Visual Studio 6 build log 267 | *.plg 268 | 269 | # Visual Studio 6 workspace options file 270 | *.opt 271 | 272 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 273 | *.vbw 274 | 275 | # Visual Studio LightSwitch build output 276 | **/*.HTMLClient/GeneratedArtifacts 277 | **/*.DesktopClient/GeneratedArtifacts 278 | **/*.DesktopClient/ModelManifest.xml 279 | **/*.Server/GeneratedArtifacts 280 | **/*.Server/ModelManifest.xml 281 | _Pvt_Extensions 282 | 283 | # Paket dependency manager 284 | .paket/paket.exe 285 | paket-files/ 286 | 287 | # FAKE - F# Make 288 | .fake/ 289 | 290 | # JetBrains Rider 291 | .idea/ 292 | *.iml 293 | 294 | # CodeRush 295 | .cr/ 296 | 297 | # Python Tools for Visual Studio (PTVS) 298 | __pycache__/ 299 | *.pyc 300 | 301 | # Cake - Uncomment if you are using it 302 | # tools/** 303 | # !tools/packages.config 304 | 305 | # Tabs Studio 306 | *.tss 307 | 308 | # Telerik's JustMock configuration file 309 | *.jmconfig 310 | 311 | # BizTalk build output 312 | *.btp.cs 313 | *.btm.cs 314 | *.odx.cs 315 | *.xsd.cs 316 | 317 | # OpenCover UI analysis results 318 | OpenCover/ 319 | 320 | # Azure Stream Analytics local run output 321 | ASALocalRun/ 322 | 323 | # MSBuild Binary and Structured Log 324 | *.binlog 325 | 326 | # NVidia Nsight GPU debugger configuration file 327 | *.nvuser 328 | 329 | # MFractors (Xamarin productivity tool) working folder 330 | .mfractor/ 331 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | true 6 | enable 7 | enable 8 | 9 | embedded 10 | true 11 | 12 | $(MSBuildThisFileDirectory)..\ 13 | 14 | true 15 | 16 | 17 | CS1591;CS1571;CS1573;CS1574;CS1723;NU1901;NU1902;NU1903; 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | false 31 | trx%3bLogFileName=$(MSBuildProjectName).trx 32 | $(MSBuildThisFileDirectory).artifacts/TestResults 33 | 34 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | false 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /ES.Kubernetes.Reflector.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31710.8 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "....Solution Items", "....Solution Items", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" 7 | ProjectSection(SolutionItems) = preProject 8 | Directory.Build.props = Directory.Build.props 9 | Directory.Packages.props = Directory.Packages.props 10 | NuGet.config = NuGet.config 11 | EndProjectSection 12 | EndProject 13 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ES.Kubernetes.Reflector", "src\ES.Kubernetes.Reflector\ES.Kubernetes.Reflector.csproj", "{D9295E0C-D3E8-1BE3-F512-1E2579A6882C}" 14 | EndProject 15 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".src", ".src", "{868DDC6B-AB77-4639-A95B-E182CC65AF60}" 16 | EndProject 17 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{755CA48A-4728-4A3F-8EAB-03E99B2F6087}" 18 | EndProject 19 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ES.Kubernetes.Reflector.Tests", "tests\ES.Kubernetes.Reflector.Tests\ES.Kubernetes.Reflector.Tests.csproj", "{4C67A712-2206-40F3-A08F-C2F9EF22B424}" 20 | EndProject 21 | Global 22 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 23 | Debug|Any CPU = Debug|Any CPU 24 | Release|Any CPU = Release|Any CPU 25 | EndGlobalSection 26 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 27 | {D9295E0C-D3E8-1BE3-F512-1E2579A6882C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {D9295E0C-D3E8-1BE3-F512-1E2579A6882C}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {D9295E0C-D3E8-1BE3-F512-1E2579A6882C}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {D9295E0C-D3E8-1BE3-F512-1E2579A6882C}.Release|Any CPU.Build.0 = Release|Any CPU 31 | {4C67A712-2206-40F3-A08F-C2F9EF22B424}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 32 | {4C67A712-2206-40F3-A08F-C2F9EF22B424}.Debug|Any CPU.Build.0 = Debug|Any CPU 33 | {4C67A712-2206-40F3-A08F-C2F9EF22B424}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {4C67A712-2206-40F3-A08F-C2F9EF22B424}.Release|Any CPU.Build.0 = Release|Any CPU 35 | EndGlobalSection 36 | GlobalSection(SolutionProperties) = preSolution 37 | HideSolutionNode = FALSE 38 | EndGlobalSection 39 | GlobalSection(NestedProjects) = preSolution 40 | {D9295E0C-D3E8-1BE3-F512-1E2579A6882C} = {868DDC6B-AB77-4639-A95B-E182CC65AF60} 41 | {4C67A712-2206-40F3-A08F-C2F9EF22B424} = {755CA48A-4728-4A3F-8EAB-03E99B2F6087} 42 | EndGlobalSection 43 | GlobalSection(ExtensibilityGlobals) = postSolution 44 | SolutionGuid = {3D60B4E7-B886-4E0A-BD09-2AF8EC1BA851} 45 | EndGlobalSection 46 | EndGlobal 47 | -------------------------------------------------------------------------------- /ES.Kubernetes.Reflector.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | E:\emberstack\kubernetes-reflector\src\Shared.DotSettings 3 | ..\Shared.DotSettings 4 | True 5 | True 6 | 1 -------------------------------------------------------------------------------- /GitVersion.yaml: -------------------------------------------------------------------------------- 1 | assembly-versioning-scheme: MajorMinorPatch 2 | assembly-file-versioning-scheme: MajorMinorPatch 3 | mode: ContinuousDelivery 4 | tag-prefix: '[vV]' 5 | continuous-delivery-fallback-tag: ci 6 | major-version-bump-message: '\+semver:\s?(breaking|major)' 7 | minor-version-bump-message: '\+semver:\s?(feature|minor)' 8 | patch-version-bump-message: '\+semver:\s?(fix|patch)' 9 | no-bump-message: '\+semver:\s?(none|skip)' 10 | legacy-semver-padding: 4 11 | build-metadata-padding: 4 12 | commits-since-version-source-padding: 4 13 | tag-pre-release-weight: 60000 14 | commit-message-incrementing: Enabled 15 | branches: 16 | develop: 17 | mode: ContinuousDeployment 18 | tag: develop 19 | increment: Minor 20 | prevent-increment-of-merged-branch-version: false 21 | track-merge-target: true 22 | regex: ^dev(elop)?(ment)?$ 23 | source-branches: [] 24 | tracks-release-branches: true 25 | is-release-branch: false 26 | is-mainline: false 27 | pre-release-weight: 0 28 | main: 29 | mode: ContinuousDelivery 30 | tag: '' 31 | increment: Patch 32 | prevent-increment-of-merged-branch-version: true 33 | track-merge-target: false 34 | regex: ^master$|^main$ 35 | source-branches: 36 | - develop 37 | - release 38 | tracks-release-branches: false 39 | is-release-branch: false 40 | is-mainline: true 41 | pre-release-weight: 55000 42 | release: 43 | mode: ContinuousDelivery 44 | tag: rc 45 | increment: None 46 | prevent-increment-of-merged-branch-version: true 47 | track-merge-target: false 48 | regex: ^releases?[/-] 49 | source-branches: 50 | - develop 51 | - main 52 | - support 53 | - release 54 | tracks-release-branches: false 55 | is-release-branch: true 56 | is-mainline: false 57 | pre-release-weight: 30000 58 | feature: 59 | mode: ContinuousDelivery 60 | tag: '{BranchName}' 61 | increment: Inherit 62 | regex: ^features?[/-] 63 | source-branches: 64 | - develop 65 | - main 66 | - release 67 | - feature 68 | - support 69 | - hotfix 70 | pre-release-weight: 30000 71 | pull-request: 72 | mode: ContinuousDelivery 73 | tag: PullRequest 74 | increment: Inherit 75 | tag-number-pattern: '[/-](?\d+)' 76 | regex: ^(pull|pull\-requests|pr)[/-] 77 | source-branches: 78 | - develop 79 | - main 80 | - release 81 | - feature 82 | - support 83 | - hotfix 84 | pre-release-weight: 30000 85 | hotfix: 86 | mode: ContinuousDelivery 87 | tag: hotfix 88 | increment: Patch 89 | prevent-increment-of-merged-branch-version: false 90 | track-merge-target: false 91 | regex: ^hotfix(es)?[/-] 92 | source-branches: 93 | - release 94 | - main 95 | - support 96 | - hotfix 97 | tracks-release-branches: false 98 | is-release-branch: false 99 | is-mainline: false 100 | pre-release-weight: 30000 101 | support: 102 | mode: ContinuousDelivery 103 | tag: '' 104 | increment: Patch 105 | prevent-increment-of-merged-branch-version: true 106 | track-merge-target: false 107 | regex: ^support[/-] 108 | source-branches: 109 | - main 110 | tracks-release-branches: false 111 | is-release-branch: false 112 | is-mainline: true 113 | pre-release-weight: 55000 114 | ignore: 115 | sha: [] 116 | increment: Inherit 117 | commit-date-format: yyyy-MM-dd 118 | merge-message-formats: {} 119 | update-build-number: true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Romeo Dumitrescu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NuGet.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reflector 2 | Reflector is a Kubernetes addon designed to monitor changes to resources (secrets and configmaps) and reflect changes to mirror resources in the same or other namespaces. 3 | 4 | [![Pipeline](https://github.com/emberstack/kubernetes-reflector/actions/workflows/pipeline.yaml/badge.svg)](https://github.com/emberstack/kubernetes-reflector/actions/workflows/pipeline.yaml) 5 | [![Release](https://img.shields.io/github/release/emberstack/kubernetes-reflector.svg?style=flat-square)](https://github.com/emberstack/kubernetes-reflector/releases/latest) 6 | [![Docker Image](https://img.shields.io/docker/image-size/emberstack/kubernetes-reflector/latest?style=flat-square)](https://hub.docker.com/r/emberstack/kubernetes-reflector) 7 | [![Docker Pulls](https://img.shields.io/docker/pulls/emberstack/kubernetes-reflector?style=flat-square)](https://hub.docker.com/r/emberstack/kubernetes-reflector) 8 | [![license](https://img.shields.io/github/license/emberstack/kubernetes-reflector.svg?style=flat-square)](LICENSE) 9 | 10 | 11 | > Supports `amd64`, `arm` and `arm64` 12 | 13 | ## Support 14 | If you need help or found a bug, please feel free to open an Issue on GitHub (https://github.com/emberstack/kubernetes-reflector/issues). 15 | 16 | ## Deployment 17 | 18 | Reflector can be deployed either manually or using Helm (recommended). 19 | 20 | ### Prerequisites 21 | - Kubernetes 1.22+ 22 | - Helm 3.8+ (if deployed using Helm) 23 | 24 | #### Deployment using Helm 25 | 26 | Use Helm to install the latest released chart: 27 | ```shellsession 28 | $ helm upgrade --install reflector oci://ghcr.io/emberstack/helm-charts/reflector 29 | ``` 30 | or 31 | ```shellsession 32 | $ helm repo add emberstack https://emberstack.github.io/helm-charts 33 | $ helm repo update 34 | $ helm upgrade --install reflector emberstack/reflector 35 | ``` 36 | 37 | You can customize the values of the helm deployment by using the following Values: 38 | 39 | | Parameter | Description | Default | 40 | | ---------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------ | 41 | | `nameOverride` | Overrides release name | `""` | 42 | | `namespaceOverride` | Overrides namespace | `""` | 43 | | `fullnameOverride` | Overrides release fullname | `""` | 44 | | `image.repository` | Container image repository | `emberstack/kubernetes-reflector` (also available: `ghcr.io/emberstack/kubernetes-reflector`) | 45 | | `image.tag` | Container image tag | `Same as chart version` | 46 | | `image.pullPolicy` | Container image pull policy | `IfNotPresent` | 47 | | `configuration.logging.minimumLevel` | Logging minimum level | `Information` | 48 | | `configuration.watcher.timeout` | Maximum watcher lifetime in seconds | `` | 49 | | `configuration.kubernetes.skipTlsVerify` | Skip TLS verify when connecting the the cluster | `false` | 50 | | `rbac.enabled` | Create and use RBAC resources | `true` | 51 | | `serviceAccount.create` | Create ServiceAccount | `true` | 52 | | `serviceAccount.name` | ServiceAccount name | _release name_ | 53 | | `livenessProbe.initialDelaySeconds` | `livenessProbe` initial delay | `5` | 54 | | `livenessProbe.periodSeconds` | `livenessProbe` period | `10` | 55 | | `readinessProbe.initialDelaySeconds` | `readinessProbe` initial delay | `5` | 56 | | `readinessProbe.periodSeconds` | `readinessProbe` period | `10` | 57 | | `startupProbe.failureThreshold` | `startupProbe` failure threshold | `10` | 58 | | `startupProbe.periodSeconds` | `startupProbe` period | `5` | 59 | | `resources` | Resource limits | `{}` | 60 | | `nodeSelector` | Node labels for pod assignment | `{}` | 61 | | `tolerations` | Toleration labels for pod assignment | `[]` | 62 | | `affinity` | Node affinity for pod assignment | `{}` | 63 | | `priorityClassName` | `priorityClassName` for pods | `""` | 64 | 65 | > Find us on [Artifact Hub](https://artifacthub.io/packages/search?org=emberstack) 66 | 67 | 68 | #### Manual deployment 69 | Each release (found on the [Releases](https://github.com/emberstack/kubernetes-reflector/releases) GitHub page) contains the manual deployment file (`reflector.yaml`). 70 | 71 | ```shellsession 72 | $ kubectl -n kube-system apply -f https://github.com/emberstack/kubernetes-reflector/releases/latest/download/reflector.yaml 73 | ``` 74 | 75 | 76 | ## Usage 77 | 78 | ### 1. Annotate the source `secret` or `configmap` 79 | 80 | - Add `reflector.v1.k8s.emberstack.com/reflection-allowed: "true"` to the resource annotations to permit reflection to mirrors. 81 | - Add `reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: ""` to the resource annotations to permit reflection from only the list of comma separated namespaces or regular expressions. Note: If this annotation is omitted or is empty, all namespaces are allowed. 82 | 83 | #### Automatic mirror creation: 84 | Reflector can create mirrors with the same name in other namespaces automatically. The following annotations control if and how the mirrors are created: 85 | - Add `reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"` to the resource annotations to automatically create mirrors in other namespaces. Note: Requires `reflector.v1.k8s.emberstack.com/reflection-allowed` to be `true` since mirrors need to able to reflect the source. 86 | - Add `reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: ""` to the resource annotations specify in which namespaces to automatically create mirrors. Note: If this annotation is omitted or is empty, all namespaces are allowed. Namespaces in this list will also be checked by `reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces` since mirrors need to be in namespaces from where reflection is permitted. 87 | 88 | > Important: If the `source` is deleted, automatic mirrors are deleted. Also if either reflection or automirroring is turned off or the automatic mirror's namespace is no longer a valid match for the allowed namespaces, the automatic mirror is deleted. 89 | 90 | > Important: Reflector will skip any conflicting resource when creating auto-mirrors. If there is already a resource with the source's name in a namespace where an automatic mirror is to be created, that namespace is skipped and logged as a warning. 91 | 92 | Example source secret: 93 | ```yaml 94 | apiVersion: v1 95 | kind: Secret 96 | metadata: 97 | name: source-secret 98 | annotations: 99 | reflector.v1.k8s.emberstack.com/reflection-allowed: "true" 100 | reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "namespace-1,namespace-2,namespace-[0-9]*" 101 | data: 102 | ... 103 | ``` 104 | 105 | Example source configmap: 106 | ```yaml 107 | apiVersion: v1 108 | kind: ConfigMap 109 | metadata: 110 | name: source-config-map 111 | annotations: 112 | reflector.v1.k8s.emberstack.com/reflection-allowed: "true" 113 | reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "namespace-1,namespace-2,namespace-[0-9]*" 114 | data: 115 | ... 116 | ``` 117 | 118 | ### 2. Annotate the mirror secret or configmap 119 | 120 | - Add `reflector.v1.k8s.emberstack.com/reflects: "/"` to the mirror object. The value of the annotation is the full name of the source object in `namespace/name` format. 121 | 122 | > Note: Add `reflector.v1.k8s.emberstack.com/reflected-version: ""` to the resource annotations when doing any manual changes to the mirror (for example when deploying with `helm` or re-applying the deployment script). This will reset the reflected version of the mirror. 123 | 124 | Example mirror secret: 125 | ```yaml 126 | apiVersion: v1 127 | kind: Secret 128 | metadata: 129 | name: mirror-secret 130 | annotations: 131 | reflector.v1.k8s.emberstack.com/reflects: "default/source-secret" 132 | data: 133 | ... 134 | ``` 135 | 136 | Example mirror configmap: 137 | ```yaml 138 | apiVersion: v1 139 | kind: ConfigMap 140 | metadata: 141 | name: mirror-config-map 142 | annotations: 143 | reflector.v1.k8s.emberstack.com/reflects: "default/source-config-map" 144 | data: 145 | ... 146 | ``` 147 | 148 | ### 3. Done! 149 | Reflector will monitor any changes done to the source objects and copy the following fields: 150 | - `data` for secrets 151 | - `data` and `binaryData` for configmaps 152 | Reflector keeps track of what was copied by annotating mirrors with the source object version. 153 | 154 | - - - - 155 | 156 | 157 | 158 | ## `cert-manager` support 159 | 160 | > Since version 1.5 of cert-manager you can annotate secrets created from certificates for mirroring using `secretTemplate` (see https://cert-manager.io/docs/usage/certificate/). 161 | 162 | ```yaml 163 | apiVersion: cert-manager.io/v1 164 | kind: Certificate 165 | ... 166 | spec: 167 | secretTemplate: 168 | annotations: 169 | reflector.v1.k8s.emberstack.com/reflection-allowed: "true" 170 | reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "" 171 | ... 172 | ``` 173 | 174 | ======= 175 | > Since version 1.15 of cert-manager you can annotate `Ingress` to create secrets created from certificates for mirroring using `cert-manager.io/secret-template` annotation (see https://github.com/cert-manager/cert-manager/pull/6839). 176 | ```yaml 177 | apiVersion: networking.k8s.io/v1 178 | kind: Ingress 179 | ... 180 | metadata: 181 | annotations: 182 | cert-manager.io/cluster-issuer: letsencrypt-prod 183 | cert-manager.io/secret-template: | 184 | {"annotations": {"reflector.v1.k8s.emberstack.com/reflection-allowed": "true", "reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces": ""}} 185 | ... 186 | ``` 187 | -------------------------------------------------------------------------------- /Shared.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | HINT 3 | HINT 4 | HINT 5 | HINT 6 | Custom Full Cleanup 7 | ExpressionBody 8 | ExpressionBody 9 | ExpressionBody 10 | True 11 | True 12 | True -------------------------------------------------------------------------------- /assets/helm_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emberstack/kubernetes-reflector/e3bc6b3112bf2909ef2c74071b098413cf9d81d1/assets/helm_icon.png -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Configuration/ReflectorOptions.cs: -------------------------------------------------------------------------------- 1 | namespace ES.Kubernetes.Reflector.Configuration; 2 | 3 | public class ReflectorOptions 4 | { 5 | public WatcherOptions? Watcher { get; set; } 6 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Configuration/WatcherOptions.cs: -------------------------------------------------------------------------------- 1 | namespace ES.Kubernetes.Reflector.Configuration; 2 | 3 | public class WatcherOptions 4 | { 5 | public int? Timeout { get; set; } 6 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS base 2 | USER app 3 | WORKDIR /app 4 | EXPOSE 8080 5 | 6 | FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim-amd64 AS build 7 | ARG BUILD_CONFIGURATION=Release 8 | COPY . . 9 | RUN dotnet build "src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj" -c $BUILD_CONFIGURATION 10 | 11 | FROM build AS publish 12 | RUN dotnet publish "src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj" -c $BUILD_CONFIGURATION -o /app/publish --no-build /p:UseAppHost=false 13 | 14 | FROM base AS final 15 | WORKDIR /app 16 | COPY --from=publish /app/publish . 17 | ENTRYPOINT ["dotnet", "ES.Kubernetes.Reflector.dll"] -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | Linux 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Mirroring/ConfigMapMirror.cs: -------------------------------------------------------------------------------- 1 | using ES.FX.Additions.KubernetesClient.Models; 2 | using ES.Kubernetes.Reflector.Mirroring.Core; 3 | using k8s; 4 | using k8s.Models; 5 | using Microsoft.AspNetCore.JsonPatch; 6 | 7 | namespace ES.Kubernetes.Reflector.Mirroring; 8 | 9 | public class ConfigMapMirror(ILogger logger, IKubernetes kubernetes) 10 | : ResourceMirror(logger, kubernetes) 11 | { 12 | protected override async Task OnResourceWithNameList(string itemRefName) => 13 | [ 14 | .. (await Kubernetes.CoreV1.ListConfigMapForAllNamespacesAsync( 15 | fieldSelector: $"metadata.name={itemRefName}")) 16 | .Items 17 | ]; 18 | 19 | protected override async Task OnResourceApplyPatch(V1Patch patch, NamespacedName refId) 20 | { 21 | await Kubernetes.CoreV1.PatchNamespacedConfigMapAsync(patch, refId.Name, refId.Namespace); 22 | } 23 | 24 | protected override Task OnResourceConfigurePatch(V1ConfigMap source, JsonPatchDocument patchDoc) 25 | { 26 | patchDoc.Replace(e => e.Data, source.Data); 27 | patchDoc.Replace(e => e.BinaryData, source.BinaryData); 28 | return Task.CompletedTask; 29 | } 30 | 31 | protected override async Task OnResourceCreate(V1ConfigMap item, string ns) 32 | { 33 | await Kubernetes.CoreV1.CreateNamespacedConfigMapAsync(item, ns); 34 | } 35 | 36 | protected override Task OnResourceClone(V1ConfigMap sourceResource) => 37 | Task.FromResult(new V1ConfigMap 38 | { 39 | ApiVersion = sourceResource.ApiVersion, 40 | Kind = sourceResource.Kind, 41 | Data = sourceResource.Data, 42 | BinaryData = sourceResource.BinaryData 43 | }); 44 | 45 | protected override async Task OnResourceDelete(NamespacedName resourceId) 46 | { 47 | await Kubernetes.CoreV1.DeleteNamespacedConfigMapAsync(resourceId.Name, resourceId.Namespace); 48 | } 49 | 50 | protected override async Task OnResourceGet(NamespacedName refId) => 51 | await Kubernetes.CoreV1.ReadNamespacedConfigMapAsync(refId.Name, refId.Namespace); 52 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Mirroring/Core/Annotations.cs: -------------------------------------------------------------------------------- 1 | namespace ES.Kubernetes.Reflector.Mirroring.Core; 2 | 3 | public static class Annotations 4 | { 5 | public const string Prefix = "reflector.v1.k8s.emberstack.com"; 6 | 7 | public static class Reflection 8 | { 9 | public static string Allowed => $"{Prefix}/reflection-allowed"; 10 | public static string AllowedNamespaces => $"{Prefix}/reflection-allowed-namespaces"; 11 | public static string AutoEnabled => $"{Prefix}/reflection-auto-enabled"; 12 | public static string AutoNamespaces => $"{Prefix}/reflection-auto-namespaces"; 13 | public static string Reflects => $"{Prefix}/reflects"; 14 | 15 | 16 | public static string MetaAutoReflects => $"{Prefix}/auto-reflects"; 17 | public static string MetaReflectedVersion => $"{Prefix}/reflected-version"; 18 | public static string MetaReflectedAt => $"{Prefix}/reflected-at"; 19 | } 20 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Mirroring/Core/MirroringProperties.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using ES.FX.Additions.KubernetesClient.Models; 3 | 4 | namespace ES.Kubernetes.Reflector.Mirroring.Core; 5 | 6 | public class MirroringProperties 7 | { 8 | public bool Allowed { get; set; } 9 | public string AllowedNamespaces { get; set; } = string.Empty; 10 | public bool AutoEnabled { get; set; } 11 | public string AutoNamespaces { get; set; } = string.Empty; 12 | public NamespacedName? Reflects { get; set; } 13 | 14 | public string ResourceVersion { get; set; } = string.Empty; 15 | 16 | 17 | public bool IsAutoReflection { get; set; } 18 | public string ReflectedVersion { get; set; } = string.Empty; 19 | public DateTimeOffset? ReflectedAt { get; set; } 20 | 21 | 22 | [MemberNotNullWhen(true, nameof(Reflects))] 23 | public bool IsReflection => Reflects != null; 24 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Mirroring/Core/MirroringPropertiesExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using ES.FX.Additions.KubernetesClient.Models; 3 | using ES.FX.Additions.KubernetesClient.Models.Extensions; 4 | using k8s; 5 | using k8s.Models; 6 | 7 | namespace ES.Kubernetes.Reflector.Mirroring.Core; 8 | 9 | public static class MirroringPropertiesExtensions 10 | { 11 | public static MirroringProperties GetMirroringProperties(this IKubernetesObject resource) => 12 | resource.EnsureMetadata().GetMirroringProperties(); 13 | 14 | public static MirroringProperties GetMirroringProperties(this V1ObjectMeta metadata) => 15 | new() 16 | { 17 | ResourceVersion = metadata.ResourceVersion, 18 | 19 | Allowed = metadata 20 | .TryGetAnnotationValue(Annotations.Reflection.Allowed, out bool allowed) && allowed, 21 | 22 | AllowedNamespaces = metadata 23 | .TryGetAnnotationValue(Annotations.Reflection.AllowedNamespaces, out string? allowedNamespaces) 24 | ? allowedNamespaces ?? string.Empty 25 | : string.Empty, 26 | 27 | AutoEnabled = metadata 28 | .TryGetAnnotationValue(Annotations.Reflection.AutoEnabled, out bool autoEnabled) && autoEnabled, 29 | 30 | AutoNamespaces = metadata 31 | .TryGetAnnotationValue(Annotations.Reflection.AutoNamespaces, out string? autoNamespaces) 32 | ? autoNamespaces ?? string.Empty 33 | : string.Empty, 34 | 35 | Reflects = metadata 36 | .TryGetAnnotationValue(Annotations.Reflection.Reflects, out string? metaReflects) 37 | ? NamespacedName.TryParse(metaReflects, out var id) ? id : null 38 | : null, 39 | 40 | IsAutoReflection = metadata 41 | .TryGetAnnotationValue(Annotations.Reflection.MetaAutoReflects, 42 | out bool metaAutoReflects) && 43 | metaAutoReflects, 44 | 45 | ReflectedVersion = metadata 46 | .TryGetAnnotationValue(Annotations.Reflection.MetaReflectedVersion, out string? reflectedVersion) 47 | ? string.IsNullOrWhiteSpace(reflectedVersion) ? string.Empty : reflectedVersion 48 | : string.Empty, 49 | 50 | ReflectedAt = metadata 51 | .TryGetAnnotationValue(Annotations.Reflection.MetaReflectedAt, out string? reflectedAtString) 52 | ? string.IsNullOrWhiteSpace(reflectedAtString) 53 | ? null 54 | : DateTimeOffset.Parse(reflectedAtString.Replace("\"", string.Empty)) 55 | : null 56 | }; 57 | 58 | public static bool CanBeReflectedToNamespace(this MirroringProperties properties, string ns) => 59 | properties.Allowed && PatternListMatch(properties.AllowedNamespaces, ns); 60 | 61 | 62 | public static bool CanBeAutoReflectedToNamespace(this MirroringProperties properties, string ns) => 63 | properties.CanBeReflectedToNamespace(ns) && properties.AutoEnabled && 64 | PatternListMatch(properties.AutoNamespaces, ns); 65 | 66 | 67 | private static bool PatternListMatch(string patternList, string value) 68 | { 69 | if (string.IsNullOrEmpty(patternList)) return true; 70 | var regexPatterns = patternList.Split([","], StringSplitOptions.RemoveEmptyEntries); 71 | return regexPatterns.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()) 72 | .Select(pattern => Regex.Match(value, pattern)) 73 | .Any(match => match.Success && match.Value.Length == value.Length); 74 | } 75 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Mirroring/Core/ResourceMirror.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Net; 3 | using ES.FX.Additions.KubernetesClient.Models; 4 | using ES.FX.Additions.KubernetesClient.Models.Extensions; 5 | using ES.FX.Additions.Newtonsoft.Json.Serialization; 6 | using ES.Kubernetes.Reflector.Watchers.Core.Events; 7 | using k8s; 8 | using k8s.Autorest; 9 | using k8s.Models; 10 | using Microsoft.AspNetCore.JsonPatch; 11 | using Newtonsoft.Json; 12 | 13 | namespace ES.Kubernetes.Reflector.Mirroring.Core; 14 | 15 | public abstract class ResourceMirror(ILogger logger, IKubernetes kubernetes) : 16 | IWatcherEventHandler, IWatcherClosedHandler 17 | where TResource : class, IKubernetesObject 18 | { 19 | private readonly ConcurrentDictionary> _autoReflectionCache = new(); 20 | private readonly ConcurrentDictionary _autoSources = new(); 21 | private readonly ConcurrentDictionary> _directReflectionCache = new(); 22 | 23 | private readonly ConcurrentDictionary _notFoundCache = new(); 24 | private readonly ConcurrentDictionary _propertiesCache = new(); 25 | protected readonly IKubernetes Kubernetes = kubernetes; 26 | protected readonly ILogger Logger = logger; 27 | 28 | 29 | /// 30 | /// Handles notifications 31 | /// 32 | public Task Handle(WatcherClosed notification, CancellationToken cancellationToken) 33 | { 34 | //If not TResource or Namespace, not something this instance should handle 35 | if (notification.ResourceType != typeof(TResource) && 36 | notification.ResourceType != typeof(V1Namespace)) return Task.CompletedTask; 37 | 38 | Logger.LogDebug("Cleared sources for {Type} resources", typeof(TResource).Name); 39 | 40 | _autoSources.Clear(); 41 | _notFoundCache.Clear(); 42 | _propertiesCache.Clear(); 43 | _autoReflectionCache.Clear(); 44 | 45 | return Task.CompletedTask; 46 | } 47 | 48 | /// 49 | /// Handles notifications 50 | /// 51 | public async Task Handle(WatcherEvent notification, CancellationToken cancellationToken) 52 | { 53 | switch (notification.Item) 54 | { 55 | case TResource obj: 56 | if (await OnResourceIgnoreCheck(obj)) return; 57 | var objNsName = obj.ObjectReference().NamespacedName(); 58 | 59 | Logger.LogTrace("Handling {eventType} {resourceType} {resourceNsName}", 60 | notification.EventType, obj.Kind, obj.NamespacedName()); 61 | 62 | 63 | //Remove from the not found, since it exists 64 | _notFoundCache.Remove(obj.NamespacedName(), out _); 65 | 66 | switch (notification.EventType) 67 | { 68 | case WatchEventType.Added: 69 | case WatchEventType.Modified: 70 | await HandleUpsert(obj, cancellationToken); 71 | break; 72 | case WatchEventType.Deleted: 73 | { 74 | _propertiesCache.Remove(objNsName, out _); 75 | var properties = obj.GetMirroringProperties(); 76 | 77 | if (!properties.IsReflection) 78 | { 79 | if (properties is { Allowed: true, AutoEnabled: true } && 80 | _autoReflectionCache.TryGetValue(objNsName, out var reflectionList)) 81 | foreach (var reflectionNsName in reflectionList.ToArray()) 82 | { 83 | Logger.LogDebug("Deleting {objNsName} - Source {sourceNsName} has been deleted", 84 | reflectionNsName, objNsName); 85 | await OnResourceDelete(reflectionNsName); 86 | } 87 | 88 | _autoSources.Remove(objNsName, out _); 89 | _directReflectionCache.Remove(objNsName, out _); 90 | _autoReflectionCache.Remove(objNsName, out _); 91 | } 92 | else 93 | { 94 | foreach (var item in _directReflectionCache) item.Value.Remove(objNsName); 95 | foreach (var item in _autoReflectionCache) item.Value.Remove(objNsName); 96 | } 97 | } 98 | break; 99 | case WatchEventType.Error: 100 | case WatchEventType.Bookmark: 101 | default: 102 | return; 103 | } 104 | 105 | break; 106 | case V1Namespace ns when notification.EventType == WatchEventType.Added: 107 | { 108 | Logger.LogTrace("Handling {eventType} {resourceType} {resourceRef}", notification.EventType, ns.Kind, 109 | ns.ObjectReference().NamespacedName()); 110 | 111 | //Update all auto-sources 112 | foreach (var sourceNsName in _autoSources.Keys) 113 | { 114 | var properties = _propertiesCache[sourceNsName]; 115 | 116 | //If it can't be reflected to this namespace, skip 117 | if (!properties.CanBeAutoReflectedToNamespace(ns.Name())) continue; 118 | 119 | 120 | //Get the list of auto-reflections 121 | var autoReflections = _autoReflectionCache.GetOrAdd(sourceNsName, []); 122 | 123 | var reflectionNsName = sourceNsName with { Namespace = ns.Name() }; 124 | 125 | //Reflect the auto-source to the new namespace 126 | await ResourceReflect( 127 | sourceNsName, 128 | reflectionNsName, 129 | null, 130 | null, 131 | true); 132 | 133 | autoReflections.Add(reflectionNsName); 134 | } 135 | } 136 | break; 137 | } 138 | } 139 | 140 | 141 | private async Task HandleUpsert(TResource obj, CancellationToken cancellationToken) 142 | { 143 | var objNsName = obj.NamespacedName(); 144 | var objProperties = obj.GetMirroringProperties(); 145 | 146 | _propertiesCache.AddOrUpdate(objNsName, objProperties, 147 | (_, _) => objProperties); 148 | 149 | switch (objProperties) 150 | { 151 | //If the resource is not a reflection 152 | case { IsReflection: false }: 153 | { 154 | //Remove any direct reflections that are no longer valid 155 | if (_directReflectionCache.TryGetValue(objNsName, out var reflectionList)) 156 | { 157 | var reflections = reflectionList 158 | .Where(s => !objProperties.CanBeReflectedToNamespace(s.Namespace)) 159 | .ToHashSet(); 160 | 161 | foreach (var reflectionNsName in reflections) 162 | { 163 | Logger.LogInformation( 164 | "Source {sourceNsName} no longer permits the direct reflection to {reflectionNsName}.", 165 | objNsName, reflectionNsName); 166 | reflectionList.Remove(reflectionNsName); 167 | } 168 | } 169 | 170 | 171 | //Delete any cached auto-reflections that are no longer valid 172 | if (_autoReflectionCache.TryGetValue(objNsName, out reflectionList)) 173 | { 174 | var reflections = reflectionList 175 | .Where(s => !objProperties.CanBeReflectedToNamespace(s.Namespace)) 176 | .ToHashSet(); 177 | foreach (var reflectionNsName in reflections) 178 | { 179 | reflectionList.Remove(reflectionNsName); 180 | 181 | Logger.LogInformation( 182 | "Source {sourceNsName} no longer permits the auto reflection to {reflectionNsName}. " + 183 | "Deleting {reflectionNsName}.", 184 | objNsName, reflectionNsName, reflectionNsName); 185 | await OnResourceDelete(reflectionNsName); 186 | } 187 | } 188 | 189 | 190 | var isAutoSource = objProperties is { Allowed: true, AutoEnabled: true }; 191 | 192 | //Update the status of an auto-source 193 | _autoSources.AddOrUpdate(objNsName, isAutoSource, (_, _) => isAutoSource); 194 | 195 | //If not allowed or auto is disabled, remove the cache for auto-reflections 196 | if (!isAutoSource) _autoReflectionCache.Remove(objNsName, out _); 197 | 198 | //If reflection is disabled, remove the reflections cache and stop reflecting 199 | if (!objProperties.Allowed) 200 | { 201 | _directReflectionCache.Remove(objNsName, out _); 202 | return; 203 | } 204 | 205 | //Update known permitted direct reflections 206 | if (_directReflectionCache.TryGetValue(objNsName, out reflectionList)) 207 | foreach (var reflectionNsName in reflectionList.ToArray()) 208 | { 209 | //Try to get the properties for the reflection. Otherwise, remove it 210 | if (!_propertiesCache.TryGetValue(reflectionNsName, out var reflectionProperties)) 211 | { 212 | reflectionList.Remove(reflectionNsName); 213 | continue; 214 | } 215 | 216 | if (reflectionProperties.ReflectedVersion == objProperties.ResourceVersion) 217 | { 218 | Logger.LogDebug( 219 | "Skipping {reflectionNsName} - Source {sourceNsName} matches reflected version", 220 | reflectionNsName, objNsName); 221 | continue; 222 | } 223 | 224 | //Execute the reflection 225 | await ResourceReflect(objNsName, 226 | reflectionNsName, 227 | obj, 228 | null, 229 | false); 230 | } 231 | 232 | //Ensure updated auto-reflections 233 | if (isAutoSource) await AutoReflectionForSource(objNsName, obj, cancellationToken); 234 | 235 | 236 | return; 237 | } 238 | //If resource is a direct reflection 239 | case { IsReflection: true, IsAutoReflection: false }: 240 | { 241 | var sourceNsName = objProperties.Reflects; 242 | MirroringProperties sourceProperties; 243 | if (!_propertiesCache.TryGetValue(sourceNsName, out var sourceProps)) 244 | { 245 | var sourceObj = await TryResourceGet(sourceNsName); 246 | if (sourceObj is null) 247 | { 248 | Logger.LogWarning( 249 | "Could not update {reflectionNsName} - Source {sourceNsName} could not be found.", 250 | objNsName, sourceNsName); 251 | return; 252 | } 253 | 254 | sourceProperties = sourceObj.GetMirroringProperties(); 255 | } 256 | else 257 | { 258 | sourceProperties = sourceProps; 259 | } 260 | 261 | _propertiesCache.AddOrUpdate(sourceNsName, 262 | sourceProperties, (_, _) => sourceProperties); 263 | _directReflectionCache.TryAdd(sourceNsName, []); 264 | _directReflectionCache[sourceNsName].Add(objNsName); 265 | 266 | if (!sourceProperties.CanBeReflectedToNamespace(objNsName.Namespace)) 267 | { 268 | Logger.LogWarning("Could not update {reflectionNsName} - Source {sourceNsName} does not permit it.", 269 | objNsName, sourceNsName); 270 | 271 | _directReflectionCache[sourceNsName] 272 | .Remove(objNsName); 273 | return; 274 | } 275 | 276 | if (sourceProperties.ResourceVersion == objProperties.ReflectedVersion) 277 | { 278 | Logger.LogDebug("Skipping {reflectionNsName} - Source {sourceNsName} matches reflected version", 279 | objNsName, sourceNsName); 280 | return; 281 | } 282 | 283 | await ResourceReflect( 284 | sourceNsName, 285 | objNsName, 286 | null, 287 | obj, 288 | false); 289 | 290 | return; 291 | } 292 | //If this is an auto-reflection, ensure it still has a source. reflection will be done when we hit the source 293 | case { IsReflection: true, IsAutoReflection: true }: 294 | { 295 | var sourceNsName = objProperties.Reflects; 296 | 297 | //If the source is known to not exist, drop the reflection 298 | if (_notFoundCache.ContainsKey(sourceNsName)) 299 | { 300 | Logger.LogInformation("Source {sourceNsName} no longer exists. Deleting {reflectionNsName}.", 301 | sourceNsName, objNsName); 302 | await OnResourceDelete(objNsName); 303 | return; 304 | } 305 | 306 | 307 | //Find the source resource 308 | MirroringProperties sourceProperties; 309 | if (!_propertiesCache.TryGetValue(sourceNsName, out var props)) 310 | { 311 | var sourceResource = await TryResourceGet(sourceNsName); 312 | if (sourceResource is null) 313 | { 314 | Logger.LogInformation("Source {sourceNsName} no longer exists. Deleting {reflectionNsName}.", 315 | sourceNsName, objNsName); 316 | await OnResourceDelete(objNsName); 317 | return; 318 | } 319 | 320 | sourceProperties = sourceResource.GetMirroringProperties(); 321 | } 322 | else 323 | { 324 | sourceProperties = props; 325 | } 326 | 327 | _propertiesCache.AddOrUpdate(sourceNsName, sourceProperties, 328 | (_, _) => sourceProperties); 329 | if (!sourceProperties.CanBeAutoReflectedToNamespace(objNsName.Namespace)) 330 | { 331 | Logger.LogInformation( 332 | "Source {sourceNsName} no longer permits the auto reflection to {reflectionNsName}. Deleting {reflectionNsName}.", 333 | sourceNsName, objNsName, 334 | objNsName); 335 | await OnResourceDelete(objNsName); 336 | } 337 | 338 | break; 339 | } 340 | } 341 | } 342 | 343 | 344 | private async Task AutoReflectionForSource(NamespacedName sourceNsName, TResource? sourceObj, 345 | CancellationToken cancellationToken) 346 | { 347 | Logger.LogDebug("Processing auto-reflection source {sourceNsName}", sourceNsName); 348 | var sourceProperties = _propertiesCache[sourceNsName]; 349 | 350 | var autoReflectionList = _autoReflectionCache 351 | .GetOrAdd(sourceNsName, _ => []); 352 | 353 | var matches = await OnResourceWithNameList(sourceNsName.Name); 354 | var namespaces = (await Kubernetes.CoreV1 355 | .ListNamespaceAsync(cancellationToken: cancellationToken)).Items; 356 | 357 | foreach (var match in matches) 358 | { 359 | var matchProperties = match.GetMirroringProperties(); 360 | _propertiesCache.AddOrUpdate(match.ObjectReference().NamespacedName(), 361 | _ => matchProperties, (_, _) => matchProperties); 362 | } 363 | 364 | var toDelete = matches 365 | .Where(s => s.Namespace() != sourceNsName.Namespace) 366 | .Where(m => !sourceProperties.CanBeAutoReflectedToNamespace(m.Namespace())) 367 | .Where(m => m.GetMirroringProperties().Reflects == sourceNsName) 368 | .Select(s => s.NamespacedName()) 369 | .ToList(); 370 | 371 | foreach (var reference in toDelete) await OnResourceDelete(reference); 372 | 373 | sourceObj ??= await TryResourceGet(sourceNsName); 374 | if (sourceObj is null) return; 375 | 376 | var toCreate = namespaces 377 | .Where(s => s.Name() != sourceNsName.Namespace) 378 | .Where(s => 379 | matches.All(m => m.Namespace() != s.Name()) && 380 | sourceProperties.CanBeAutoReflectedToNamespace(s.Name())) 381 | .Select(s => new NamespacedName(s.Name(), sourceNsName.Name)).ToList(); 382 | 383 | var toUpdate = matches 384 | .Where(s => s.Namespace() != sourceNsName.Namespace) 385 | .Where(m => !toDelete.Contains(m.NamespacedName()) && !toCreate.Contains(m.NamespacedName()) && 386 | m.GetMirroringProperties().ReflectedVersion != sourceProperties.ResourceVersion && 387 | m.GetMirroringProperties().Reflects == sourceNsName) 388 | .Select(m => m.NamespacedName()).ToList(); 389 | 390 | var toSkip = matches 391 | .Where(s => s.Namespace() != sourceNsName.Namespace) 392 | .Where(m => 393 | !toDelete.Contains(m.NamespacedName()) && 394 | !toCreate.Contains(m.NamespacedName()) && 395 | m.GetMirroringProperties().ReflectedVersion == sourceProperties.ResourceVersion && 396 | m.GetMirroringProperties().Reflects == sourceNsName) 397 | .Select(m => m.NamespacedName()).ToList(); 398 | 399 | 400 | autoReflectionList.Clear(); 401 | foreach (var item in toCreate 402 | .Concat(toSkip) 403 | .Concat(toUpdate) 404 | .ToHashSet()) 405 | autoReflectionList.Add(item); 406 | 407 | foreach (var reflectionNsName in toCreate) 408 | await ResourceReflect( 409 | sourceNsName, 410 | reflectionNsName, 411 | sourceObj, 412 | null, 413 | true); 414 | foreach (var reflectionRef in toUpdate) 415 | { 416 | var reflectionObj = matches.Single(s => s.NamespacedName() == reflectionRef); 417 | await ResourceReflect( 418 | sourceNsName, 419 | reflectionRef, 420 | sourceObj, 421 | reflectionObj, 422 | true); 423 | } 424 | 425 | Logger.LogInformation( 426 | "Auto-reflected {sourceNsName} where permitted. " + 427 | "Created {createdCount} - Updated {updatedCount} - Deleted {deletedCount} - Validated {skippedCount}.", 428 | sourceNsName, toCreate.Count, toUpdate.Count, toDelete.Count, toSkip.Count); 429 | } 430 | 431 | 432 | private async Task ResourceReflect(NamespacedName sourceNsName, NamespacedName reflectionNsName, 433 | TResource? sourceObj, 434 | TResource? reflectionObj, bool autoReflection) 435 | { 436 | if (sourceNsName == reflectionNsName) return; 437 | 438 | Logger.LogDebug("Reflecting {sourceNsName} to {reflectionNsName}", sourceNsName, reflectionNsName); 439 | 440 | TResource source; 441 | if (sourceObj is null) 442 | { 443 | var lookup = await TryResourceGet(sourceNsName); 444 | if (lookup is not null) 445 | { 446 | source = lookup; 447 | } 448 | else 449 | { 450 | Logger.LogWarning("Could not update {reflectionNsName} - Source {sourceNsName} could not be found.", 451 | reflectionNsName, sourceNsName); 452 | return; 453 | } 454 | } 455 | else 456 | { 457 | source = sourceObj; 458 | } 459 | 460 | 461 | var patchAnnotations = new Dictionary 462 | { 463 | [Annotations.Reflection.MetaAutoReflects] = autoReflection.ToString(), 464 | [Annotations.Reflection.Reflects] = sourceNsName.ToString(), 465 | [Annotations.Reflection.MetaReflectedVersion] = source.Metadata.ResourceVersion, 466 | [Annotations.Reflection.MetaReflectedAt] = 467 | JsonConvert.SerializeObject(DateTimeOffset.UtcNow).Replace("\"", string.Empty) 468 | }; 469 | 470 | 471 | try 472 | { 473 | if (reflectionObj is null) 474 | { 475 | var newResource = await OnResourceClone(source); 476 | newResource.Metadata ??= new V1ObjectMeta(); 477 | newResource.Metadata.Name = reflectionNsName.Name; 478 | newResource.Metadata.NamespaceProperty = reflectionNsName.Namespace; 479 | newResource.Metadata.Annotations ??= new Dictionary(); 480 | var newResourceAnnotations = newResource.Metadata.Annotations; 481 | foreach (var patchAnnotation in patchAnnotations) 482 | newResourceAnnotations[patchAnnotation.Key] = patchAnnotation.Value; 483 | newResourceAnnotations[Annotations.Reflection.MetaAutoReflects] = autoReflection.ToString(); 484 | newResourceAnnotations[Annotations.Reflection.Reflects] = sourceNsName.ToString(); 485 | newResourceAnnotations[Annotations.Reflection.MetaReflectedVersion] = source.Metadata.ResourceVersion; 486 | newResourceAnnotations[Annotations.Reflection.MetaReflectedAt] = DateTimeOffset.UtcNow.ToString("O"); 487 | 488 | try 489 | { 490 | await OnResourceCreate(newResource, reflectionNsName.Namespace); 491 | Logger.LogInformation("Created {reflectionNsName} as a reflection of {sourceNsName}", 492 | reflectionNsName, sourceNsName); 493 | return; 494 | } 495 | catch (HttpOperationException ex) when (ex.Response.StatusCode == HttpStatusCode.Conflict) 496 | { 497 | //If resource already exists, set target and fallback to patch 498 | reflectionObj = await OnResourceGet(reflectionNsName); 499 | } 500 | } 501 | 502 | if (reflectionObj.GetMirroringProperties().ReflectedVersion == source.Metadata.ResourceVersion) 503 | { 504 | Logger.LogDebug("Skipping {reflectionNsName} - Source {sourceNsName} matches reflected version", 505 | reflectionNsName, sourceNsName); 506 | return; 507 | } 508 | 509 | 510 | var patchDoc = new JsonPatchDocument([], new JsonPropertyNameContractResolver()); 511 | var annotations = new Dictionary(reflectionObj.Metadata.Annotations); 512 | foreach (var patchAnnotation in patchAnnotations) 513 | annotations[patchAnnotation.Key] = patchAnnotation.Value; 514 | patchDoc.Replace(e => e.Metadata.Annotations, annotations); 515 | 516 | await OnResourceConfigurePatch(source, patchDoc); 517 | 518 | var patch = JsonConvert.SerializeObject(patchDoc, Formatting.Indented); 519 | await OnResourceApplyPatch(new V1Patch(patch, V1Patch.PatchType.JsonPatch), reflectionNsName); 520 | Logger.LogInformation("Patched {reflectionNsName} as a reflection of {sourceNsName}", 521 | reflectionNsName, sourceNsName); 522 | } 523 | catch (Exception ex) 524 | { 525 | Logger.LogError(ex, "Could not reflect {sourceNsName} to {reflectionNsName} due to exception.", 526 | sourceNsName, reflectionNsName); 527 | } 528 | } 529 | 530 | 531 | protected abstract Task OnResourceApplyPatch(V1Patch source, NamespacedName refId); 532 | protected abstract Task OnResourceConfigurePatch(TResource source, JsonPatchDocument patchDoc); 533 | protected abstract Task OnResourceCreate(TResource item, string ns); 534 | protected abstract Task OnResourceClone(TResource sourceResource); 535 | protected abstract Task OnResourceDelete(NamespacedName resourceId); 536 | 537 | 538 | protected abstract Task OnResourceWithNameList(string itemRefName); 539 | 540 | private async Task TryResourceGet(NamespacedName resourceNsName) 541 | { 542 | try 543 | { 544 | Logger.LogDebug("Retrieving {id}", resourceNsName); 545 | var resource = await OnResourceGet(resourceNsName); 546 | _notFoundCache.TryRemove(resourceNsName, out _); 547 | return resource; 548 | } 549 | catch (HttpOperationException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) 550 | { 551 | Logger.LogDebug("Could not find {nsName}", resourceNsName); 552 | _notFoundCache.TryAdd(resourceNsName, true); 553 | return null; 554 | } 555 | } 556 | 557 | protected abstract Task OnResourceGet(NamespacedName refId); 558 | 559 | protected virtual Task OnResourceIgnoreCheck(TResource item) => Task.FromResult(false); 560 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Mirroring/SecretMirror.cs: -------------------------------------------------------------------------------- 1 | using ES.FX.Additions.KubernetesClient.Models; 2 | using ES.Kubernetes.Reflector.Mirroring.Core; 3 | using k8s; 4 | using k8s.Models; 5 | using Microsoft.AspNetCore.JsonPatch; 6 | 7 | namespace ES.Kubernetes.Reflector.Mirroring; 8 | 9 | public class SecretMirror(ILogger logger, IKubernetes kubernetesClient) 10 | : ResourceMirror(logger, kubernetesClient) 11 | { 12 | protected override async Task OnResourceWithNameList(string itemRefName) => 13 | [ 14 | .. (await Kubernetes.CoreV1.ListSecretForAllNamespacesAsync( 15 | fieldSelector: $"metadata.name={itemRefName}")) 16 | .Items 17 | ]; 18 | 19 | protected override async Task OnResourceApplyPatch(V1Patch patch, NamespacedName refId) 20 | { 21 | await Kubernetes.CoreV1.PatchNamespacedSecretWithHttpMessagesAsync(patch, refId.Name, refId.Namespace); 22 | } 23 | 24 | protected override Task OnResourceConfigurePatch(V1Secret source, JsonPatchDocument patchDoc) 25 | { 26 | patchDoc.Replace(e => e.Data, source.Data); 27 | return Task.CompletedTask; 28 | } 29 | 30 | protected override async Task OnResourceCreate(V1Secret item, string ns) 31 | { 32 | await Kubernetes.CoreV1.CreateNamespacedSecretAsync(item, ns); 33 | } 34 | 35 | protected override Task OnResourceClone(V1Secret sourceResource) => 36 | Task.FromResult(new V1Secret 37 | { 38 | ApiVersion = sourceResource.ApiVersion, 39 | Kind = sourceResource.Kind, 40 | Type = sourceResource.Type, 41 | Data = sourceResource.Data 42 | }); 43 | 44 | protected override async Task OnResourceDelete(NamespacedName resourceId) 45 | { 46 | await Kubernetes.CoreV1.DeleteNamespacedSecretAsync(resourceId.Name, resourceId.Namespace); 47 | } 48 | 49 | protected override async Task OnResourceGet(NamespacedName refId) => 50 | await Kubernetes.CoreV1.ReadNamespacedSecretAsync(refId.Name, refId.Namespace); 51 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Program.cs: -------------------------------------------------------------------------------- 1 | using ES.FX.Additions.KubernetesClient.Models.Extensions; 2 | using ES.FX.Additions.Serilog.Lifetime; 3 | using ES.FX.Hosting.Lifetime; 4 | using ES.FX.Ignite.Hosting; 5 | using ES.FX.Ignite.KubernetesClient.Hosting; 6 | using ES.FX.Ignite.OpenTelemetry.Exporter.Seq.Hosting; 7 | using ES.FX.Ignite.Serilog.Hosting; 8 | using ES.Kubernetes.Reflector.Configuration; 9 | using ES.Kubernetes.Reflector.Mirroring; 10 | using ES.Kubernetes.Reflector.Watchers; 11 | using ES.Kubernetes.Reflector.Watchers.Core.Events; 12 | using k8s.Models; 13 | 14 | return await ProgramEntry.CreateBuilder(args).UseSerilog().Build().RunAsync(async _ => 15 | { 16 | var builder = WebApplication.CreateBuilder(args); 17 | 18 | builder.Configuration.AddEnvironmentVariables("ES_"); 19 | 20 | builder.Logging.ClearProviders(); 21 | builder.Ignite(); 22 | builder.IgniteSerilog(config => 23 | config.Destructure.ByTransforming(v => v.NamespacedName())); 24 | builder.IgniteSeqOpenTelemetryExporter(); 25 | builder.IgniteKubernetesClient(); 26 | 27 | builder.Services.Configure(builder.Configuration.GetSection(nameof(ES.Kubernetes.Reflector))); 28 | 29 | builder.Services.AddHostedService(); 30 | builder.Services.AddHostedService(); 31 | builder.Services.AddHostedService(); 32 | 33 | builder.Services.AddSingleton(); 34 | builder.Services.AddSingleton(); 35 | 36 | builder.Services.AddSingleton(sp => sp.GetRequiredService()); 37 | builder.Services.AddSingleton(sp => sp.GetRequiredService()); 38 | 39 | builder.Services.AddSingleton(sp => sp.GetRequiredService()); 40 | builder.Services.AddSingleton(sp => sp.GetRequiredService()); 41 | 42 | var app = builder.Build(); 43 | app.Ignite(); 44 | await app.RunAsync(); 45 | return 0; 46 | }); 47 | 48 | public partial class Program; -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "HOST": { 4 | "commandName": "Project", 5 | "launchBrowser": false, 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "applicationUrl": "http://0.0.0.0:8080", 10 | "dotnetRunMessages": false 11 | }, 12 | "Docker": { 13 | "commandName": "Docker", 14 | "launchBrowser": true, 15 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", 16 | "publishAllPorts": true 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Watchers/ConfigMapWatcher.cs: -------------------------------------------------------------------------------- 1 | using ES.Kubernetes.Reflector.Configuration; 2 | using ES.Kubernetes.Reflector.Watchers.Core; 3 | using ES.Kubernetes.Reflector.Watchers.Core.Events; 4 | using k8s; 5 | using k8s.Autorest; 6 | using k8s.Models; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace ES.Kubernetes.Reflector.Watchers; 10 | 11 | public class ConfigMapWatcher( 12 | ILogger logger, 13 | IKubernetes kubernetes, 14 | IOptionsMonitor options, 15 | IEnumerable watcherEventHandlers, 16 | IEnumerable watcherClosedHandlers) 17 | : WatcherBackgroundService( 18 | logger, options, watcherEventHandlers, watcherClosedHandlers) 19 | { 20 | protected override Task> OnGetWatcher(CancellationToken cancellationToken) => 21 | kubernetes.CoreV1.ListConfigMapForAllNamespacesWithHttpMessagesAsync(watch: true, 22 | timeoutSeconds: WatcherTimeout, 23 | cancellationToken: cancellationToken); 24 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Watchers/Core/Events/IWatcherClosedHandler.cs: -------------------------------------------------------------------------------- 1 | namespace ES.Kubernetes.Reflector.Watchers.Core.Events; 2 | 3 | public interface IWatcherClosedHandler 4 | { 5 | public Task Handle(WatcherClosed e, CancellationToken cancellationToken); 6 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Watchers/Core/Events/IWatcherEventHandler.cs: -------------------------------------------------------------------------------- 1 | namespace ES.Kubernetes.Reflector.Watchers.Core.Events; 2 | 3 | public interface IWatcherEventHandler 4 | { 5 | public Task Handle(WatcherEvent e, CancellationToken cancellationToken); 6 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Watchers/Core/Events/WatcherClosed.cs: -------------------------------------------------------------------------------- 1 | namespace ES.Kubernetes.Reflector.Watchers.Core.Events; 2 | 3 | public class WatcherClosed 4 | { 5 | public required Type ResourceType { get; set; } 6 | public bool Faulted { get; set; } 7 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Watchers/Core/Events/WatcherEvent.cs: -------------------------------------------------------------------------------- 1 | using k8s; 2 | using k8s.Models; 3 | 4 | namespace ES.Kubernetes.Reflector.Watchers.Core.Events; 5 | 6 | public class WatcherEvent 7 | { 8 | public WatchEventType EventType { get; set; } 9 | public IKubernetesObject? Item { get; set; } 10 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Watchers/Core/WatcherBackgroundService.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Threading.Channels; 3 | using ES.Kubernetes.Reflector.Configuration; 4 | using ES.Kubernetes.Reflector.Watchers.Core.Events; 5 | using k8s; 6 | using k8s.Autorest; 7 | using k8s.Models; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace ES.Kubernetes.Reflector.Watchers.Core; 11 | 12 | public abstract class WatcherBackgroundService( 13 | ILogger logger, 14 | IOptionsMonitor options, 15 | IEnumerable watcherEventHandlers, 16 | IEnumerable watcherClosedHandlers) 17 | : BackgroundService 18 | where TResource : IKubernetesObject 19 | { 20 | protected int WatcherTimeout => options.CurrentValue.Watcher?.Timeout ?? 3600; 21 | 22 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 23 | { 24 | var sessionStopwatch = new Stopwatch(); 25 | while (!stoppingToken.IsCancellationRequested) 26 | { 27 | var sessionFaulted = false; 28 | sessionStopwatch.Restart(); 29 | 30 | using var absoluteTimeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(WatcherTimeout + 3)); 31 | using var cancellationCts = 32 | CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, absoluteTimeoutCts.Token); 33 | var cancellationToken = cancellationCts.Token; 34 | 35 | var eventChannel = Channel.CreateBounded(new BoundedChannelOptions(256) 36 | { 37 | FullMode = BoundedChannelFullMode.Wait 38 | }); 39 | 40 | try 41 | { 42 | logger.LogInformation("Requesting {type} resources", typeof(TResource).Name); 43 | 44 | //Read using a separate task so the watcher doesn't get stuck waiting on subscribers to handle the event 45 | _ = Task.Run(async () => 46 | { 47 | while (!cancellationToken.IsCancellationRequested) 48 | { 49 | var watcherEvent = await eventChannel.Reader.ReadAsync(cancellationToken) 50 | .ConfigureAwait(false); 51 | foreach (var watcherEventHandler in watcherEventHandlers) 52 | await watcherEventHandler.Handle(new WatcherEvent 53 | { 54 | Item = watcherEvent.Item, 55 | EventType = watcherEvent.EventType 56 | }, cancellationToken); 57 | } 58 | }, cancellationToken); 59 | 60 | using var watcher = OnGetWatcher(cancellationToken); 61 | var watchList = watcher.WatchAsync(cancellationToken: cancellationToken); 62 | 63 | try 64 | { 65 | await foreach (var (type, item) in watchList) 66 | { 67 | if (await OnResourceIgnoreCheck(item)) continue; 68 | await eventChannel.Writer.WriteAsync(new WatcherEvent 69 | { 70 | Item = item, 71 | EventType = type 72 | }, cancellationToken).ConfigureAwait(false); 73 | } 74 | } 75 | catch (OperationCanceledException) 76 | { 77 | logger.LogTrace("Event channel writing canceled."); 78 | } 79 | } 80 | catch (TaskCanceledException) 81 | { 82 | logger.LogTrace("Session canceled using token."); 83 | } 84 | catch (Exception exception) 85 | { 86 | logger.LogError(exception, "Faulted due to exception."); 87 | sessionFaulted = true; 88 | } 89 | finally 90 | { 91 | eventChannel.Writer.Complete(); 92 | while (eventChannel.Reader.TryRead(out _)) ; 93 | 94 | var sessionElapsed = sessionStopwatch.Elapsed; 95 | sessionStopwatch.Stop(); 96 | logger.LogInformation("Session closed. Duration: {duration}. Faulted: {faulted}.", sessionElapsed, 97 | sessionFaulted); 98 | 99 | foreach (var handler in watcherClosedHandlers) 100 | await handler.Handle(new WatcherClosed 101 | { 102 | ResourceType = typeof(TResource), 103 | Faulted = sessionFaulted 104 | }, stoppingToken); 105 | } 106 | } 107 | } 108 | 109 | protected abstract Task> OnGetWatcher(CancellationToken cancellationToken); 110 | 111 | protected virtual Task OnResourceIgnoreCheck(TResource item) => Task.FromResult(false); 112 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Watchers/NamespaceWatcher.cs: -------------------------------------------------------------------------------- 1 | using ES.Kubernetes.Reflector.Configuration; 2 | using ES.Kubernetes.Reflector.Watchers.Core; 3 | using ES.Kubernetes.Reflector.Watchers.Core.Events; 4 | using k8s; 5 | using k8s.Autorest; 6 | using k8s.Models; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace ES.Kubernetes.Reflector.Watchers; 10 | 11 | public class NamespaceWatcher( 12 | ILogger logger, 13 | IKubernetes kubernetes, 14 | IOptionsMonitor options, 15 | IEnumerable watcherEventHandlers, 16 | IEnumerable watcherClosedHandlers) 17 | : WatcherBackgroundService( 18 | logger, options, watcherEventHandlers, watcherClosedHandlers) 19 | { 20 | protected override Task> OnGetWatcher(CancellationToken cancellationToken) => 21 | kubernetes.CoreV1.ListNamespaceWithHttpMessagesAsync(watch: true, timeoutSeconds: WatcherTimeout, 22 | cancellationToken: cancellationToken); 23 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/Watchers/SecretWatcher.cs: -------------------------------------------------------------------------------- 1 | using ES.Kubernetes.Reflector.Configuration; 2 | using ES.Kubernetes.Reflector.Watchers.Core; 3 | using ES.Kubernetes.Reflector.Watchers.Core.Events; 4 | using k8s; 5 | using k8s.Autorest; 6 | using k8s.Models; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace ES.Kubernetes.Reflector.Watchers; 10 | 11 | public class SecretWatcher( 12 | ILogger logger, 13 | IKubernetes kubernetes, 14 | IOptionsMonitor options, 15 | IEnumerable watcherEventHandlers, 16 | IEnumerable watcherClosedHandlers) 17 | : WatcherBackgroundService( 18 | logger, options, watcherEventHandlers, watcherClosedHandlers) 19 | { 20 | protected override Task> OnGetWatcher(CancellationToken cancellationToken) => 21 | kubernetes.CoreV1.ListSecretForAllNamespacesWithHttpMessagesAsync(watch: true, 22 | timeoutSeconds: WatcherTimeout, 23 | cancellationToken: cancellationToken); 24 | 25 | protected override Task OnResourceIgnoreCheck(V1Secret item) 26 | { 27 | //Skip helm version secrets. This can cause a terrible amount of traffic. 28 | var ignore = item.Type.StartsWith("helm.sh"); 29 | return Task.FromResult(ignore); 30 | } 31 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "MinimumLevel": { 4 | "Override": { 5 | "ES.Kubernetes.Reflector": "Verbose" 6 | } 7 | } 8 | }, 9 | 10 | "Ignite": { 11 | "OpenTelemetry": { 12 | "Exporter": { 13 | "Seq": { 14 | "IngestionEndpoint": "http://seq.localenv.io:5341", 15 | "HealthUrl": "http://seq.localenv.io/health", 16 | "Settings": { 17 | "Enabled": true 18 | } 19 | } 20 | } 21 | }, 22 | "KubernetesClient": { 23 | "SkipTlsVerify": true 24 | } 25 | }, 26 | "Reflector": { 27 | "Watcher": { 28 | "Timeout": "" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/ES.Kubernetes.Reflector/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Serilog": { 3 | "Using": [ "Serilog.Sinks.Seq" ], 4 | "LevelSwitches": { "$consoleLevelSwitch": "Verbose" }, 5 | "MinimumLevel": { 6 | "Default": "Verbose", 7 | "Override": { 8 | "Microsoft": "Information", 9 | "System.Net.Http": "Warning", 10 | "Polly": "Warning", 11 | "Microsoft.Hosting.Lifetime": "Information", 12 | "Microsoft.AspNetCore": "Warning", 13 | "Microsoft.AspNetCore.DataProtection": "Error", 14 | "ES.FX": "Information", 15 | "ES.Kubernetes.Reflector": "Information" 16 | } 17 | }, 18 | "WriteTo": [ 19 | { 20 | "Name": "Console", 21 | "Args": { 22 | "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] ({SourceContext}) {Message:lj}{NewLine}{Exception}", 23 | "levelSwitch": "$consoleLevelSwitch", 24 | "formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" 25 | } 26 | } 27 | ] 28 | }, 29 | "Ignite": { 30 | "Settings": { 31 | "Configuration": { 32 | "AdditionalJsonSettingsFiles": [], 33 | "AdditionalJsonAppSettingsOverrides": [ "overrides" ] 34 | }, 35 | "OpenTelemetry": { 36 | "AspNetCoreTracingHealthChecksRequestsFiltered": true 37 | } 38 | }, 39 | "OpenTelemetry": { 40 | "Exporter": { 41 | "Seq": { 42 | "Settings": { 43 | "Enabled": false 44 | } 45 | } 46 | } 47 | }, 48 | "KubernetesClient": { 49 | "SkipTlsVerify": false 50 | } 51 | }, 52 | "Reflector": { 53 | "Watcher": { 54 | "Timeout": "" 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /src/helm/reflector/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /src/helm/reflector/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: reflector 3 | description: A Helm chart to deploy Reflector 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.0 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: 0.1.0 25 | 26 | 27 | icon: https://raw.githubusercontent.com/emberstack/kubernetes-reflector/main/assets/helm_icon.png 28 | keywords: 29 | - reflector 30 | - controller 31 | - copy 32 | - secrets 33 | - configmaps 34 | - cert-manager 35 | - certificates 36 | home: https://github.com/emberstack/kubernetes-reflector 37 | sources: 38 | - https://github.com/emberstack/kubernetes-reflector 39 | maintainers: 40 | - name: winromulus 41 | email: helm-charts@emberstack.com 42 | -------------------------------------------------------------------------------- /src/helm/reflector/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | Reflector can now be used to perform automatic copy actions on secrets and configmaps. -------------------------------------------------------------------------------- /src/helm/reflector/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "reflector.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "reflector.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "reflector.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Expand the namespace of the release. 35 | Allows overriding it for multi-namespace deployments in combined charts. 36 | */}} 37 | {{- define "reflector.namespace" -}} 38 | {{- default .Release.Namespace .Values.namespaceOverride | trunc 63 | trimSuffix "-" -}} 39 | {{- end -}} 40 | 41 | {{/* 42 | Common labels 43 | */}} 44 | {{- define "reflector.labels" -}} 45 | helm.sh/chart: {{ include "reflector.chart" . }} 46 | {{ include "reflector.selectorLabels" . }} 47 | {{- if .Chart.AppVersion }} 48 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 49 | {{- end }} 50 | app.kubernetes.io/managed-by: {{ .Release.Service }} 51 | {{- end }} 52 | 53 | {{/* 54 | Selector labels 55 | */}} 56 | {{- define "reflector.selectorLabels" -}} 57 | app.kubernetes.io/name: {{ include "reflector.name" . }} 58 | app.kubernetes.io/instance: {{ .Release.Name }} 59 | {{- end }} 60 | 61 | {{/* 62 | Create the name of the service account to use 63 | */}} 64 | {{- define "reflector.serviceAccountName" -}} 65 | {{- if .Values.serviceAccount.create }} 66 | {{- default (include "reflector.fullname" .) .Values.serviceAccount.name }} 67 | {{- else }} 68 | {{- default "default" .Values.serviceAccount.name }} 69 | {{- end }} 70 | {{- end }} 71 | -------------------------------------------------------------------------------- /src/helm/reflector/templates/clusterRole.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.enabled }} 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: {{ include "reflector.fullname" . }} 6 | namespace: {{ include "reflector.namespace" . }} 7 | labels: 8 | {{- include "reflector.labels" . | nindent 4 }} 9 | rules: 10 | - apiGroups: [""] 11 | resources: ["configmaps", "secrets"] 12 | verbs: ["*"] 13 | - apiGroups: [""] 14 | resources: ["namespaces"] 15 | verbs: ["watch", "list"] 16 | {{- end }} 17 | -------------------------------------------------------------------------------- /src/helm/reflector/templates/clusterRoleBinding.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.enabled }} 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRoleBinding 4 | metadata: 5 | name: {{ include "reflector.fullname" . }} 6 | namespace: {{ include "reflector.namespace" . }} 7 | labels: 8 | {{- include "reflector.labels" . | nindent 4 }} 9 | roleRef: 10 | kind: ClusterRole 11 | name: {{ include "reflector.fullname" . }} 12 | apiGroup: rbac.authorization.k8s.io 13 | subjects: 14 | - kind: ServiceAccount 15 | name: {{ include "reflector.serviceAccountName" . }} 16 | namespace: {{ include "reflector.namespace" . }} 17 | {{- end }} 18 | -------------------------------------------------------------------------------- /src/helm/reflector/templates/cron.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.cron.enabled }} 2 | --- 3 | apiVersion: {{ .Values.cron.apiVersion | default "batch/v1" }} 4 | kind: CronJob 5 | metadata: 6 | name: {{ include "reflector.fullname" . }} 7 | namespace: {{ include "reflector.namespace" . }} 8 | labels: 9 | {{- include "reflector.labels" . | nindent 4 }} 10 | 11 | spec: 12 | schedule: {{ .Values.cron.schedule | quote }} 13 | suspend: {{ default false .Values.cron.suspend }} 14 | concurrencyPolicy: {{ default "Forbid" .Values.cron.concurrencyPolicy }} 15 | successfulJobsHistoryLimit: {{ default "5" .Values.cron.successfulJobsHistoryLimit }} 16 | failedJobsHistoryLimit: {{ default "5" .Values.cron.failedJobsHistoryLimit }} 17 | {{- if .Values.cron.startingDeadlineSeconds }} 18 | startingDeadlineSeconds: {{ .Values.cron.startingDeadlineSeconds }} 19 | {{- end }} 20 | jobTemplate: 21 | spec: 22 | {{- if .Values.cron.activeDeadlineSeconds }} 23 | activeDeadlineSeconds: {{ .Values.cron.activeDeadlineSeconds }} 24 | {{- end }} 25 | template: 26 | metadata: 27 | {{- with .Values.podAnnotations }} 28 | annotations: 29 | {{- toYaml . | nindent 12 }} 30 | {{- end }} 31 | labels: 32 | {{- include "reflector.selectorLabels" . | nindent 12 }} 33 | spec: 34 | serviceAccountName: {{ include "reflector.serviceAccountName" . }} 35 | 36 | {{- with .Values.imagePullSecrets }} 37 | imagePullSecrets: 38 | {{- toYaml . | nindent 12 }} 39 | {{- end }} 40 | 41 | {{- with .Values.affinity }} 42 | affinity: 43 | {{- toYaml . | nindent 12 }} 44 | {{- end }} 45 | 46 | {{- with .Values.nodeSelector }} 47 | nodeSelector: 48 | {{- toYaml . | nindent 12 }} 49 | {{- end }} 50 | 51 | {{- with .Values.tolerations }} 52 | tolerations: 53 | {{- toYaml . | nindent 12 }} 54 | {{- end }} 55 | 56 | {{- with .Values.cron.securityContext }} 57 | securityContext: 58 | {{- toYaml . | nindent 12 }} 59 | {{- end }} 60 | 61 | restartPolicy: {{ .Values.cron.restartPolicy | default "Never" }} 62 | containers: 63 | - name: {{ .Chart.Name }} 64 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 65 | imagePullPolicy: {{ .Values.image.pullPolicy }} 66 | env: 67 | - name: ES_Serilog__MinimumLevel__Default 68 | value: {{ .Values.configuration.logging.minimumLevel | quote }} 69 | - name: ES_Reflector__Watcher__Timeout 70 | value: {{ .Values.configuration.watcher.timeout | quote }} 71 | - name: ES_Ignite__KubernetesClient__SkipTlsVerify 72 | value: {{ .Values.configuration.kubernetes.skipTlsVerify | quote }} 73 | {{- with .Values.extraEnv }} 74 | {{- toYaml . | nindent 12 }} 75 | {{- end }} 76 | resources: 77 | {{- toYaml .Values.resources | nindent 16 }} 78 | {{- end }} 79 | -------------------------------------------------------------------------------- /src/helm/reflector/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | {{- if not .Values.cron.enabled }} 2 | --- 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: {{ include "reflector.fullname" . }} 7 | namespace: {{ include "reflector.namespace" . }} 8 | labels: 9 | {{- include "reflector.labels" . | nindent 4 }} 10 | 11 | spec: 12 | {{- if not .Values.autoscaling.enabled }} 13 | replicas: {{ .Values.replicaCount }} 14 | {{- end }} 15 | selector: 16 | matchLabels: 17 | {{- include "reflector.selectorLabels" . | nindent 6 }} 18 | template: 19 | metadata: 20 | {{- with .Values.podAnnotations }} 21 | annotations: 22 | {{- toYaml . | nindent 8 }} 23 | {{- end }} 24 | labels: 25 | {{- include "reflector.selectorLabels" . | nindent 8 }} 26 | {{- with .Values.podLabels }} 27 | {{- toYaml . | nindent 8 }} 28 | {{- end }} 29 | 30 | spec: 31 | {{- with .Values.imagePullSecrets }} 32 | imagePullSecrets: 33 | {{- toYaml . | nindent 8 }} 34 | {{- end }} 35 | serviceAccountName: {{ include "reflector.serviceAccountName" . }} 36 | securityContext: 37 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 38 | {{- if .Values.priorityClassName }} 39 | priorityClassName: {{ .Values.priorityClassName }} 40 | {{- end }} 41 | containers: 42 | - name: {{ .Chart.Name }} 43 | securityContext: 44 | {{- toYaml .Values.securityContext | nindent 12 }} 45 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 46 | imagePullPolicy: {{ .Values.image.pullPolicy }} 47 | env: 48 | - name: ES_Serilog__MinimumLevel__Default 49 | value: {{ .Values.configuration.logging.minimumLevel | quote }} 50 | - name: ES_Reflector__Watcher__Timeout 51 | value: {{ .Values.configuration.watcher.timeout | quote }} 52 | - name: ES_Ignite__KubernetesClient__SkipTlsVerify 53 | value: {{ .Values.configuration.kubernetes.skipTlsVerify | quote }} 54 | {{- with .Values.extraEnv }} 55 | {{- toYaml . | nindent 12 }} 56 | {{- end }} 57 | ports: 58 | - name: http 59 | containerPort: 8080 60 | protocol: TCP 61 | livenessProbe: 62 | {{- toYaml .Values.livenessProbe | nindent 12 }} 63 | readinessProbe: 64 | {{- toYaml .Values.readinessProbe | nindent 12 }} 65 | {{- if semverCompare ">= 1.18-0" .Capabilities.KubeVersion.Version }} 66 | startupProbe: 67 | {{- toYaml .Values.startupProbe | nindent 12 }} 68 | {{- end }} 69 | resources: 70 | {{- toYaml .Values.resources | nindent 12 }} 71 | volumeMounts: 72 | {{- toYaml .Values.volumeMounts | nindent 12 }} 73 | {{- with .Values.nodeSelector }} 74 | nodeSelector: 75 | {{- toYaml . | nindent 8 }} 76 | {{- end }} 77 | {{- with .Values.affinity }} 78 | affinity: 79 | {{- toYaml . | nindent 8 }} 80 | {{- end }} 81 | {{- with .Values.tolerations }} 82 | tolerations: 83 | {{- toYaml . | nindent 8 }} 84 | {{- end }} 85 | {{- with .Values.volumes }} 86 | volumes: 87 | {{- toYaml . | nindent 8}} 88 | {{- end }} 89 | {{- with .Values.topologySpreadConstraints }} 90 | topologySpreadConstraints: 91 | {{- toYaml . | nindent 8 }} 92 | {{- end }} 93 | {{- end }} 94 | -------------------------------------------------------------------------------- /src/helm/reflector/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "reflector.fullname" . }} 6 | namespace: {{ include "reflector.namespace" . }} 7 | labels: 8 | {{- include "reflector.labels" . | nindent 4 }} 9 | spec: 10 | scaleTargetRef: 11 | apiVersion: apps/v1 12 | kind: Deployment 13 | name: {{ include "reflector.fullname" . }} 14 | minReplicas: {{ .Values.autoscaling.minReplicas }} 15 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 16 | metrics: 17 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 18 | - type: Resource 19 | resource: 20 | name: cpu 21 | target: 22 | type: Utilization 23 | averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 24 | {{- end }} 25 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 26 | - type: Resource 27 | resource: 28 | name: memory 29 | target: 30 | type: Utilization 31 | averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 32 | {{- end }} 33 | {{- end }} 34 | -------------------------------------------------------------------------------- /src/helm/reflector/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "reflector.serviceAccountName" . }} 6 | namespace: {{ include "reflector.namespace" . }} 7 | labels: 8 | {{- include "reflector.labels" . | nindent 4 }} 9 | {{- with .Values.serviceAccount.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /src/helm/reflector/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for reflector. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | image: 8 | repository: docker.io/emberstack/kubernetes-reflector 9 | pullPolicy: IfNotPresent 10 | # Overrides the image tag whose default is the chart appVersion. 11 | tag: "" 12 | 13 | imagePullSecrets: [] 14 | nameOverride: "" 15 | namespaceOverride: "" 16 | fullnameOverride: "" 17 | 18 | cron: 19 | enabled: false 20 | schedule: "*/15 * * * *" 21 | activeDeadlineSeconds: 600 22 | securityContext: 23 | runAsNonRoot: true 24 | runAsUser: 1000 25 | 26 | configuration: 27 | logging: 28 | minimumLevel: Information 29 | watcher: 30 | timeout: "" 31 | kubernetes: 32 | skipTlsVerify: false 33 | 34 | rbac: 35 | enabled: true 36 | 37 | serviceAccount: 38 | # Specifies whether a service account should be created 39 | create: true 40 | # Annotations to add to the service account 41 | annotations: {} 42 | # The name of the service account to use. 43 | # If not set and create is true, a name is generated using the fullname template 44 | name: "" 45 | 46 | # additional annotations to set on the pod 47 | podAnnotations: {} 48 | # additional labels to set on the pod 49 | podLabels: {} 50 | # additional env vars to add to the pod 51 | extraEnv: [] 52 | 53 | podSecurityContext: 54 | fsGroup: 2000 55 | 56 | securityContext: 57 | capabilities: 58 | drop: 59 | - ALL 60 | readOnlyRootFilesystem: true 61 | runAsNonRoot: true 62 | runAsUser: 1000 63 | 64 | livenessProbe: 65 | httpGet: 66 | path: /health/live 67 | port: http 68 | timeoutSeconds: 10 69 | initialDelaySeconds: 5 70 | periodSeconds: 10 71 | failureThreshold: 5 72 | readinessProbe: 73 | httpGet: 74 | path: /health/ready 75 | port: http 76 | timeoutSeconds: 10 77 | initialDelaySeconds: 5 78 | periodSeconds: 10 79 | failureThreshold: 5 80 | startupProbe: 81 | httpGet: 82 | path: /health/ready 83 | port: http 84 | timeoutSeconds: 10 85 | initialDelaySeconds: 5 86 | periodSeconds: 10 87 | failureThreshold: 5 88 | 89 | 90 | resources: 91 | {} 92 | # We usually recommend not to specify default resources and to leave this as a conscious 93 | # choice for the user. This also increases chances charts run on environments with little 94 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 95 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 96 | # limits: 97 | # cpu: 100m 98 | # memory: 128Mi 99 | # requests: 100 | # cpu: 100m 101 | # memory: 128Mi 102 | 103 | autoscaling: 104 | enabled: false 105 | minReplicas: 1 106 | maxReplicas: 100 107 | targetCPUUtilizationPercentage: 80 108 | # targetMemoryUtilizationPercentage: 80 109 | 110 | nodeSelector: {} 111 | 112 | tolerations: [] 113 | 114 | affinity: {} 115 | 116 | topologySpreadConstraints: [] 117 | 118 | priorityClassName: "" 119 | 120 | volumes: [] 121 | 122 | volumeMounts: [] -------------------------------------------------------------------------------- /tests/ES.Kubernetes.Reflector.Tests/Additions/ReflectorAnnotationsBuilder.cs: -------------------------------------------------------------------------------- 1 | using ES.Kubernetes.Reflector.Mirroring.Core; 2 | 3 | namespace ES.Kubernetes.Reflector.Tests.Additions; 4 | 5 | public sealed class ReflectorAnnotationsBuilder 6 | { 7 | private readonly Dictionary _annotations = new(); 8 | 9 | public ReflectorAnnotationsBuilder WithReflectionAllowed(bool allowed) 10 | { 11 | _annotations[Annotations.Reflection.Allowed] = allowed.ToString().ToLower(); 12 | return this; 13 | } 14 | 15 | public ReflectorAnnotationsBuilder WithAllowedNamespaces(params string[] namespaces) 16 | { 17 | _annotations[Annotations.Reflection.AllowedNamespaces] = string.Join(",", namespaces); 18 | return this; 19 | } 20 | 21 | public ReflectorAnnotationsBuilder WithAutoEnabled(bool enabled) 22 | { 23 | _annotations[Annotations.Reflection.AutoEnabled] = enabled.ToString().ToLower(); 24 | return this; 25 | } 26 | 27 | public ReflectorAnnotationsBuilder WithAutoNamespaces(bool enabled, params string[] namespaces) 28 | { 29 | _annotations[Annotations.Reflection.AutoNamespaces] = enabled ? string.Join(",", namespaces) : string.Empty; 30 | return this; 31 | } 32 | 33 | public Dictionary Build() 34 | { 35 | if (_annotations.Count != 0) return _annotations; 36 | 37 | _annotations[Annotations.Reflection.Allowed] = "true"; 38 | _annotations[Annotations.Reflection.AllowedNamespaces] = string.Empty; 39 | _annotations[Annotations.Reflection.AutoEnabled] = "true"; 40 | _annotations[Annotations.Reflection.AutoNamespaces] = string.Empty; 41 | return _annotations; 42 | } 43 | } -------------------------------------------------------------------------------- /tests/ES.Kubernetes.Reflector.Tests/ES.Kubernetes.Reflector.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | all 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | all 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /tests/ES.Kubernetes.Reflector.Tests/Fixtures/KubernetesFixture.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using k8s; 3 | using Testcontainers.K3s; 4 | 5 | namespace ES.Kubernetes.Reflector.Tests.Fixtures; 6 | 7 | public sealed class KubernetesFixture : IAsyncLifetime 8 | { 9 | public K3sContainer Container { get; } = new K3sBuilder() 10 | .WithName($"{nameof(KubernetesFixture)}-{Guid.CreateVersion7()}") 11 | .Build(); 12 | 13 | public async ValueTask DisposeAsync() => await Container.DisposeAsync(); 14 | 15 | public async ValueTask InitializeAsync() => await Container.StartAsync(); 16 | 17 | public async Task GetKubernetesClientConfiguration() => 18 | await KubernetesClientConfiguration 19 | .BuildConfigFromConfigFileAsync( 20 | new MemoryStream(Encoding.UTF8.GetBytes( 21 | await Container.GetKubeconfigAsync()))); 22 | 23 | public async Task GetKubernetesClient() => 24 | new k8s.Kubernetes(await GetKubernetesClientConfiguration()); 25 | } -------------------------------------------------------------------------------- /tests/ES.Kubernetes.Reflector.Tests/Fixtures/ReflectorFixture.cs: -------------------------------------------------------------------------------- 1 | using k8s; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Microsoft.AspNetCore.Mvc.Testing; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Hosting; 6 | 7 | namespace ES.Kubernetes.Reflector.Tests.Fixtures; 8 | 9 | public sealed class ReflectorFixture : WebApplicationFactory, IAsyncLifetime 10 | { 11 | private static readonly Lock Lock = new(); 12 | 13 | public KubernetesClientConfiguration? KubernetesClientConfiguration { get; set; } 14 | 15 | 16 | public async ValueTask InitializeAsync() => await Task.CompletedTask; 17 | 18 | // https://github.com/serilog/serilog-aspnetcore/issues/289 19 | // https://github.com/dotnet/AspNetCore.Docs/issues/26609 20 | protected override IHost CreateHost(IHostBuilder builder) 21 | { 22 | lock (Lock) 23 | { 24 | return base.CreateHost(builder); 25 | } 26 | } 27 | 28 | protected override void ConfigureWebHost(IWebHostBuilder builder) 29 | { 30 | builder.UseEnvironment("tests"); 31 | builder.ConfigureServices(services => 32 | { 33 | var kubernetesClientConfiguration = services.SingleOrDefault( 34 | d => d.ServiceType == typeof(KubernetesClientConfiguration)); 35 | if (kubernetesClientConfiguration is not null) services.Remove(kubernetesClientConfiguration); 36 | 37 | services.AddSingleton(s => 38 | { 39 | var config = KubernetesClientConfiguration ?? 40 | KubernetesClientConfiguration.BuildDefaultConfig(); 41 | config.HttpClientTimeout = TimeSpan.FromMinutes(30); 42 | 43 | return config; 44 | }); 45 | }); 46 | } 47 | } -------------------------------------------------------------------------------- /tests/ES.Kubernetes.Reflector.Tests/Integration/Base/BaseIntegrationTest.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using ES.Kubernetes.Reflector.Tests.Integration.Fixtures; 3 | using k8s; 4 | using k8s.Autorest; 5 | using k8s.Models; 6 | using Polly; 7 | using Polly.Retry; 8 | 9 | namespace ES.Kubernetes.Reflector.Tests.Integration.Base; 10 | 11 | public class BaseIntegrationTest(ReflectorIntegrationFixture integrationFixture) 12 | { 13 | protected static readonly ResiliencePipeline ResourceExistsResiliencePipeline = 14 | new ResiliencePipelineBuilder() 15 | .AddRetry(new RetryStrategyOptions 16 | { 17 | ShouldHandle = new PredicateBuilder() 18 | .Handle(ex => 19 | ex.Response.StatusCode == HttpStatusCode.NotFound) 20 | .HandleResult(false), 21 | MaxRetryAttempts = 10, 22 | Delay = TimeSpan.FromSeconds(1) 23 | }) 24 | .AddTimeout(TimeSpan.FromSeconds(30)) 25 | .Build(); 26 | 27 | protected async Task GetKubernetesClient() => 28 | await integrationFixture.Kubernetes.GetKubernetesClient(); 29 | 30 | protected async Task CreateNamespaceAsync(string name) 31 | { 32 | var client = await GetKubernetesClient(); 33 | var ns = new V1Namespace 34 | { 35 | ApiVersion = V1Namespace.KubeApiVersion, 36 | Kind = V1Namespace.KubeKind, 37 | Metadata = new V1ObjectMeta 38 | { 39 | Name = name 40 | } 41 | }; 42 | 43 | return await client.CoreV1.CreateNamespaceAsync(ns); 44 | } 45 | 46 | 47 | protected async Task DelayForReflection() => 48 | await Task.Delay(TimeSpan.FromSeconds(1)); 49 | } -------------------------------------------------------------------------------- /tests/ES.Kubernetes.Reflector.Tests/Integration/Fixtures/ReflectorIntegrationFixture.cs: -------------------------------------------------------------------------------- 1 | using ES.Kubernetes.Reflector.Tests.Fixtures; 2 | 3 | namespace ES.Kubernetes.Reflector.Tests.Integration.Fixtures; 4 | 5 | public class ReflectorIntegrationFixture : IAsyncLifetime 6 | { 7 | public KubernetesFixture Kubernetes { get; init; } = new(); 8 | public ReflectorFixture Reflector { get; init; } = new(); 9 | 10 | public async ValueTask InitializeAsync() 11 | { 12 | await Kubernetes.InitializeAsync(); 13 | Reflector.KubernetesClientConfiguration = 14 | await Kubernetes.GetKubernetesClientConfiguration(); 15 | await Reflector.InitializeAsync(); 16 | Reflector.CreateClient(); 17 | } 18 | 19 | public async ValueTask DisposeAsync() => await Task.CompletedTask; 20 | } -------------------------------------------------------------------------------- /tests/ES.Kubernetes.Reflector.Tests/Integration/HealthCheckIntegrationTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Threading; 3 | using ES.FX.Ignite.Configuration; 4 | using ES.Kubernetes.Reflector.Tests.Integration.Base; 5 | using ES.Kubernetes.Reflector.Tests.Integration.Fixtures; 6 | using JetBrains.Annotations; 7 | using k8s; 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.Diagnostics.HealthChecks; 10 | using Polly.Retry; 11 | using Polly; 12 | using k8s.Autorest; 13 | 14 | [assembly: AssemblyFixture(typeof(ReflectorIntegrationFixture))] 15 | 16 | namespace ES.Kubernetes.Reflector.Tests.Integration; 17 | 18 | [PublicAPI] 19 | public class HealthCheckIntegrationTests(ReflectorIntegrationFixture integrationFixture) 20 | : BaseIntegrationTest(integrationFixture) 21 | { 22 | private readonly ReflectorIntegrationFixture _integrationFixture = integrationFixture; 23 | 24 | [Fact] 25 | public async Task LivenessHealthCheck_Should_Return_Healthy() 26 | { 27 | var httpClient = _integrationFixture.Reflector.CreateClient(); 28 | var settings = _integrationFixture.Reflector.Services.GetRequiredService(); 29 | 30 | var response = await httpClient.GetAsync(settings.AspNetCore.HealthChecks.LivenessEndpointPath, 31 | TestContext.Current.CancellationToken); 32 | Assert.Equal(HttpStatusCode.OK,response.StatusCode); 33 | } 34 | 35 | [Fact] 36 | public async Task ReadinessHealthCheck_Should_Return_Healthy() 37 | { 38 | var settings = _integrationFixture.Reflector.Services.GetRequiredService(); 39 | var httpClient = _integrationFixture.Reflector.CreateClient(); 40 | 41 | var response = await httpClient.GetAsync(settings.AspNetCore.HealthChecks.ReadinessEndpointPath, 42 | TestContext.Current.CancellationToken); 43 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 44 | } 45 | } -------------------------------------------------------------------------------- /tests/ES.Kubernetes.Reflector.Tests/Integration/MirroringIntegrationTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using ES.Kubernetes.Reflector.Tests.Additions; 3 | using ES.Kubernetes.Reflector.Tests.Integration.Base; 4 | using ES.Kubernetes.Reflector.Tests.Integration.Fixtures; 5 | using JetBrains.Annotations; 6 | using k8s; 7 | using k8s.Autorest; 8 | using k8s.Models; 9 | 10 | [assembly: AssemblyFixture(typeof(ReflectorIntegrationFixture))] 11 | 12 | namespace ES.Kubernetes.Reflector.Tests.Integration; 13 | 14 | [PublicAPI] 15 | public class MirroringIntegrationTests( 16 | ReflectorIntegrationFixture integrationFixture) 17 | : BaseIntegrationTest(integrationFixture) 18 | { 19 | [Fact] 20 | public async Task AutoReflect_To_AllowedNamespaces() 21 | { 22 | var client = await GetKubernetesClient(); 23 | 24 | var allowedNamespaces = new[] 25 | { 26 | $"allowed-{Guid.CreateVersion7()}", 27 | $"allowed-{Guid.CreateVersion7()}" 28 | }; 29 | var notAllowedNamespaces = new[] 30 | { 31 | $"not-allowed-{Guid.CreateVersion7()}", 32 | $"not-allowed-{Guid.CreateVersion7()}" 33 | }; 34 | 35 | foreach (var ns in allowedNamespaces.Concat(notAllowedNamespaces)) await CreateNamespaceAsync(ns); 36 | 37 | var sourceResource = await CreateResource(client, 38 | annotations: new ReflectorAnnotationsBuilder() 39 | .WithReflectionAllowed(true) 40 | .WithAllowedNamespaces("^allowed-.*") 41 | .WithAutoEnabled(true).Build()); 42 | 43 | await DelayForReflection(); 44 | 45 | 46 | foreach (var ns in allowedNamespaces) 47 | Assert.True(await WaitForResource(client, sourceResource.Name(), ns, 48 | TestContext.Current.CancellationToken)); 49 | 50 | foreach (var ns in notAllowedNamespaces) 51 | Assert.False(await ResourceExists(client, 52 | sourceResource.Name(), ns, 53 | TestContext.Current.CancellationToken)); 54 | } 55 | 56 | 57 | [Fact] 58 | public async Task AutoReflect_To_NewNamespaces() 59 | { 60 | var client = await GetKubernetesClient(); 61 | 62 | var allowedNamespaces = new[] 63 | { 64 | $"allowed-{Guid.CreateVersion7()}", 65 | $"allowed-{Guid.CreateVersion7()}" 66 | }; 67 | 68 | var sourceResource = await CreateResource(client, 69 | annotations: new ReflectorAnnotationsBuilder() 70 | .WithReflectionAllowed(true) 71 | .WithAllowedNamespaces("^allowed-.*") 72 | .WithAutoEnabled(true).Build()); 73 | 74 | foreach (var ns in allowedNamespaces) await CreateNamespaceAsync(ns); 75 | 76 | await DelayForReflection(); 77 | 78 | foreach (var ns in allowedNamespaces) 79 | Assert.True(await WaitForResource(client, sourceResource.Name(), ns, 80 | TestContext.Current.CancellationToken)); 81 | } 82 | 83 | 84 | [Fact] 85 | public async Task AutoReflect_Remove_ReflectionsWhenResourceDeleted() 86 | { 87 | var client = await GetKubernetesClient(); 88 | 89 | var allowedNamespaces = new[] 90 | { 91 | $"allowed-{Guid.CreateVersion7()}", 92 | $"allowed-{Guid.CreateVersion7()}" 93 | }; 94 | 95 | var sourceResource = await CreateResource(client, 96 | annotations: new ReflectorAnnotationsBuilder() 97 | .WithReflectionAllowed(true) 98 | .WithAllowedNamespaces("^allowed-.*") 99 | .WithAutoEnabled(true).Build()); 100 | 101 | 102 | foreach (var ns in allowedNamespaces) await CreateNamespaceAsync(ns); 103 | 104 | foreach (var ns in allowedNamespaces) 105 | Assert.True(await WaitForResource(client, sourceResource.Name(), 106 | ns, TestContext.Current.CancellationToken)); 107 | 108 | await DeleteResource(client, sourceResource.Name(), sourceResource.Namespace(), 109 | TestContext.Current.CancellationToken); 110 | 111 | 112 | await DelayForReflection(); 113 | 114 | foreach (var ns in allowedNamespaces) 115 | Assert.False(await ResourceExists(client, 116 | sourceResource.Name(), ns, 117 | TestContext.Current.CancellationToken)); 118 | } 119 | 120 | 121 | private async Task CreateResource(IKubernetes client, 122 | string? name = null, string? namespaceName = null, 123 | Dictionary? annotations = null, 124 | Dictionary? data = null) 125 | { 126 | var sourceResource = new V1Secret 127 | { 128 | ApiVersion = V1Secret.KubeApiVersion, 129 | Kind = V1Secret.KubeKind, 130 | Metadata = new V1ObjectMeta 131 | { 132 | Name = name ?? Guid.CreateVersion7().ToString(), 133 | NamespaceProperty = namespaceName ?? Guid.CreateVersion7().ToString(), 134 | Annotations = annotations ?? new ReflectorAnnotationsBuilder().Build() 135 | }, 136 | StringData = data ?? new Dictionary 137 | { 138 | { Guid.NewGuid().ToString(), Guid.NewGuid().ToString() } 139 | }, 140 | Type = "Opaque" 141 | }; 142 | 143 | var namespaces = await client.CoreV1.ListNamespaceAsync(); 144 | var nsExists = namespaces.Items.Any(s => s.Name() == namespaceName); 145 | if (!nsExists) 146 | await CreateNamespaceAsync(sourceResource.Metadata.NamespaceProperty); 147 | sourceResource = await client.CoreV1 148 | .CreateNamespacedSecretAsync(sourceResource, sourceResource.Metadata.NamespaceProperty); 149 | return sourceResource; 150 | } 151 | 152 | private async Task DeleteResource(IKubernetes client, string name, string namespaceName, 153 | CancellationToken cancellationToken = default) 154 | { 155 | await client.CoreV1.DeleteNamespacedSecretAsync(name, namespaceName, cancellationToken: cancellationToken); 156 | } 157 | 158 | 159 | private async Task WaitForResource(IKubernetes client, string name, string namespaceName, 160 | CancellationToken cancellationToken = default) 161 | { 162 | return await ResourceExistsResiliencePipeline.ExecuteAsync(async token => 163 | { 164 | var resource = await client.CoreV1.ReadNamespacedSecretAsync( 165 | name, namespaceName, cancellationToken: token); 166 | return resource is not null; 167 | }, cancellationToken); 168 | } 169 | 170 | 171 | private async Task ResourceExists(IKubernetes client, string name, string namespaceName, 172 | CancellationToken cancellationToken = default) 173 | { 174 | bool exists; 175 | try 176 | { 177 | await client.CoreV1.ReadNamespacedSecretAsync(name, namespaceName, 178 | cancellationToken: TestContext.Current.CancellationToken); 179 | exists = true; 180 | } 181 | catch (HttpOperationException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound) 182 | { 183 | exists = false; 184 | } 185 | 186 | return exists; 187 | } 188 | } --------------------------------------------------------------------------------