├── .dockerignore ├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── Makefile ├── OSSMETADATA ├── README.md ├── RELEASE.md ├── config ├── certmanager │ ├── certificate.yaml │ ├── kustomization.yaml │ └── kustomizeconfig.yaml ├── default │ ├── kustomization.yaml │ ├── manager_auth_proxy_patch.yaml │ ├── manager_webhook_patch.yaml │ └── webhookcainjection_patch.yaml ├── manager │ ├── kustomization.yaml │ └── manager.yaml ├── prometheus │ ├── kustomization.yaml │ └── monitor.yaml ├── rbac │ ├── auth_proxy_client_clusterrole.yaml │ ├── auth_proxy_role.yaml │ ├── auth_proxy_role_binding.yaml │ ├── auth_proxy_service.yaml │ ├── kustomization.yaml │ ├── leader_election_role.yaml │ ├── leader_election_role_binding.yaml │ └── role_binding.yaml └── webhook │ ├── kustomization.yaml │ ├── kustomizeconfig.yaml │ ├── manifests.yaml │ └── service.yaml ├── deploy └── charts │ └── harbor-container-webhook │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── certificate.yaml │ ├── config.yaml │ ├── deployment.yaml │ ├── poddisruptionbudget.yaml │ ├── rbac.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ ├── servicemonitor.yaml │ └── webhook.yaml │ └── values.yaml ├── docs └── example-config │ └── dockerhub.yaml ├── go.mod ├── go.sum ├── hack ├── .gitignore ├── config.yaml ├── gencerts.sh └── test │ ├── admission.json │ └── no-op.json ├── internal ├── config │ └── config.go └── webhook │ ├── docker.go │ ├── docker_test.go │ ├── manifest.go │ ├── mutate.go │ ├── mutate_test.go │ ├── transfomer_test.go │ └── transformer.go └── main.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .vscode 4 | config/ 5 | hack/ 6 | docs/ 7 | templates/ 8 | scripts/ 9 | **/.md 10 | tilt-provider.json 11 | test/ 12 | _artifacts 13 | .tiltbuild 14 | Makefile 15 | *.md 16 | .gitignore 17 | .golangci.yml 18 | cloudbuild.yaml 19 | clusterctl-settings.json 20 | *.example 21 | OWNERS 22 | OWNERS_ALIASES 23 | PROJECT 24 | SECURITY_CONTACTS 25 | tilt_provider.json 26 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ "main" ] 7 | tags: 8 | - '0.*' 9 | - '1.*' 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | jobs: 16 | build-and-push: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | packages: write 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v2 27 | 28 | - name: Log in to the Container registry 29 | uses: docker/login-action@v2 30 | with: 31 | registry: ${{ env.REGISTRY }} 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Extract metadata (tags, labels) for Docker 36 | id: meta 37 | uses: docker/metadata-action@v4 38 | with: 39 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 40 | 41 | - name: Build and push 42 | uses: docker/build-push-action@v4 43 | with: 44 | context: . 45 | platforms: linux/amd64,linux/arm64 46 | push: true 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | bin 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Kubernetes Generated files - skip generated files, except for vendored files 17 | 18 | !vendor/**/zz_generated.* 19 | 20 | # editor and IDE paraphernalia 21 | .idea 22 | *.swp 23 | *.swo 24 | *~ 25 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 3m 3 | modules-download-mode: readonly 4 | 5 | linters-settings: 6 | goconst: 7 | min-len: 3 8 | min-occurrences: 3 9 | gocritic: 10 | enabled-tags: 11 | - diagnostic 12 | - experimental 13 | - opinionated 14 | - performance 15 | - style 16 | disabled-checks: 17 | - dupImport # https://github.com/go-critic/go-critic/issues/845 18 | - ifElseChain 19 | - octalLiteral 20 | - whyNoLint 21 | - wrapperFunc 22 | - importShadow 23 | - unnamedResult 24 | - unnecessaryBlock 25 | - unnecessaryDefer 26 | settings: 27 | rangeValCopy: 28 | sizeThreshold: 512 29 | hugeParam: 30 | sizeThreshold: 512 31 | gocyclo: 32 | min-complexity: 16 33 | lll: 34 | line-length: 300 35 | misspell: 36 | locale: US 37 | 38 | issues: 39 | exclude-rules: 40 | # Disable linters that are annoying in tests. 41 | - path: _test\.go 42 | linters: [bodyclose, gocritic, gosec, staticcheck, stylecheck] 43 | 44 | linters: 45 | disable-all: true 46 | enable: 47 | - asasalint 48 | - asciicheck 49 | - bidichk 50 | - bodyclose 51 | - contextcheck 52 | - dogsled 53 | - dupl 54 | - durationcheck 55 | - errcheck 56 | - exhaustive 57 | - gocritic 58 | - godot 59 | - gofmt 60 | - gosec 61 | - gosimple 62 | - govet 63 | - ineffassign 64 | - lll 65 | - misspell 66 | - nolintlint 67 | - prealloc 68 | - revive 69 | - staticcheck 70 | - stylecheck 71 | - typecheck 72 | - unconvert 73 | - unparam 74 | - usetesting 75 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.8.1] - 2025-03-17 8 | ### Fixed 9 | - Fixed chart pdb rendering 10 | 11 | ## [0.8.0] - 2025-03-17 12 | ### Added 13 | - Add support for topologySpreadConstraints 14 | - Add pdb to helm chart 15 | ### Changed 16 | - Changed to support multiple image pull secret values in a secret 17 | - Updated downstream kubernetes libraries 18 | ### Fixed 19 | - Fixed rbac necessary for image pull secrets to correctly reference a cluster role 20 | ### Removed 21 | - Removed image mutation metrics as they had too high cardinality vs usefulness. 22 | 23 | ## [0.7.1] - 2025-01-13 24 | ### Changed 25 | - Updated downstream libraries, go version, etc 26 | 27 | ## [0.7.0] - 2024-01-31 28 | ### Changed 29 | - Instead of querying for the node architecture and os when inspecting pods, which rarely worked, use `platforms` on the config to determine which platforms should be required when checking upstream. 30 | 31 | ## [0.6.3] - 2024-01-26 32 | ### Fixed 33 | - Fixed rewrite_success prometheus metric counting every rule invocation, instead of only rewrites 34 | ### Changed 35 | - Added helm usage to the README.md 36 | 37 | ## [0.6.2] - 2023-12-06 38 | ### Fixed 39 | - Fixed ServiceMonitor templates in helm chart not rendering correctly (thanks @z0rc for the fix!) 40 | ### Changed 41 | - Updated go.mod dependencies 42 | 43 | ## [0.6.1] - 2023-12-04 44 | ### Added 45 | - Added cluster role permissions for list, watch on nodes 46 | 47 | ## [0.6.0] - 2023-12-04 48 | ### Added 49 | - Support for authenticating to check if manifests exist for each proxy rule with an image pull secret. 50 | ### Changed 51 | - Changes to the helm chart RBAC to support access secrets within the webhook's namespace. 52 | - Some minor test refactoring. 53 | - Deprecated kube-client-lazy-remap flag (no-op now), it has graduated to default controller runtime behavior 54 | 55 | ## [0.5.0] - 2023-05-12 56 | ### Added 57 | - Added cli flags for passing kube client qps, burst, and enabling lazy rest mapping of resources in the controller-runtime 58 | 59 | ## [0.4.2] - 2023-04-24 60 | ### Changed 61 | - Changed node lookup for pod submissions to fail-open and default to webhook's OS and architecture 62 | 63 | ## [0.4.1] - 2023-03-28 64 | ### Fixed 65 | - Fixed node lookup with untyped client, did not pass struct pointer correctly 66 | ### Changed 67 | - Improved logging around rejected pod submissions due to node lookup. 68 | 69 | ## [0.4.0] - 2023-03-23 70 | ### Added 71 | - Added detection of the pod OS, architecture for manifests 72 | - Added cluster role and bindings for accessing node resources 73 | ### Changed 74 | - Rebuilt and upgraded modules 75 | 76 | ## [0.3.5] - 2022-10-17 77 | ### Changed 78 | - Added volumes, volume mounts, init containers to the helm chart 79 | - Rebuilt and upgraded modules, other minor tlc. 80 | 81 | ## [0.3.4] - 2021-11-15 82 | ### Added 83 | - New prometheus metric `hcw.mutations.image_rewrite` which tracks the original and rewritten image modified 84 | 85 | ## [0.3.2] - 2021-10-05 86 | ### Fixed 87 | - Chart handling of `rules` and `extraRules` was incorrect when unset. 88 | 89 | ## [0.3.0] - 2021-10-04 90 | ### Changed 91 | - Rewrote significant parts of the implementation and configuration to switch to a new regex based rules system. 92 | ### Fixed 93 | - Chart version supports cloud vendor prelease suffixes 94 | 95 | ## [0.2.0] - 2020-10-29 96 | ### Changed 97 | - Rewrote webhook to use containers/image reference parsing instead of regex 98 | ### Added 99 | - Added verbose mode flag 100 | 101 | ## [0.1.0] - 2020-10-19 102 | ### Added 103 | - Initial release 104 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [opensource@indeed.com](mailto:opensource@indeed.com). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM --platform=$BUILDPLATFORM golang:1.24 as builder 3 | 4 | WORKDIR /workspace 5 | # Copy the Go Modules manifests 6 | COPY go.mod go.mod 7 | COPY go.sum go.sum 8 | # cache deps before building and copying source so that we don't need to re-download as much 9 | # and so that source changes don't invalidate our downloaded layer 10 | # Cache deps before building and copying source so that we don't need to re-download as much 11 | # and so that source changes don't invalidate our downloaded layer 12 | RUN --mount=type=cache,target=/root/.local/share/golang \ 13 | --mount=type=cache,target=/go/pkg/mod \ 14 | go mod download 15 | 16 | # Copy the go source 17 | COPY ./ ./ 18 | 19 | ARG TARGETARCH 20 | 21 | # Build 22 | RUN --mount=type=cache,target=/root/.cache/go-build \ 23 | --mount=type=cache,target=/go/pkg/mod \ 24 | --mount=type=cache,target=/root/.local/share/golang \ 25 | CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -a -o harbor-container-webhook main.go 26 | 27 | # Use distroless as minimal base image to package the manager binary 28 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 29 | FROM gcr.io/distroless/static:nonroot 30 | WORKDIR / 31 | COPY --from=builder /workspace/harbor-container-webhook . 32 | 33 | # Use uid of nonroot user (65532) because kubernetes expects numeric user when applying pod security policies 34 | USER 65532 35 | 36 | ENTRYPOINT ["/harbor-container-webhook"] 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # set the shell to bash always 2 | SHELL := /bin/bash 3 | 4 | # set make and shell flags to exit on errors 5 | MAKEFLAGS += --warn-undefined-variables 6 | .SHELLFLAGS := -euo pipefail -c 7 | 8 | ARCH = amd64 9 | BUILD_ARGS ?= 10 | 11 | DOCKER_BUILD_PLATFORMS = linux/amd64,linux/arm64 12 | DOCKER_BUILDX_BUILDER ?= "harbor-container-webhook" 13 | 14 | # default target is build 15 | .DEFAULT_GOAL := all 16 | .PHONY: all 17 | all: $(addprefix build-,$(ARCH)) 18 | 19 | # Image registry for build/push image targets 20 | IMAGE_REGISTRY ?= ghcr.io/indeedeng/harbor-container-webhook 21 | 22 | HELM_DIR ?= deploy/charts/harbor-container-webhook 23 | 24 | OUTPUT_DIR ?= bin 25 | 26 | RUN_GOLANGCI_LINT := go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 27 | 28 | # check if there are any existing `git tag` values 29 | ifeq ($(shell git tag),) 30 | # no tags found - default to initial tag `v0.0.0` 31 | VERSION ?= $(shell echo "v0.0.0-$$(git rev-list HEAD --count)-g$$(git describe --dirty --always)" | sed 's/-/./2' | sed 's/-/./2') 32 | else 33 | # use tags 34 | VERSION ?= $(shell git describe --dirty --always --tags --exclude 'helm*' | sed 's/-/./2' | sed 's/-/./2') 35 | endif 36 | 37 | # ==================================================================================== 38 | # Colors 39 | 40 | BLUE := $(shell printf "\033[34m") 41 | YELLOW := $(shell printf "\033[33m") 42 | RED := $(shell printf "\033[31m") 43 | GREEN := $(shell printf "\033[32m") 44 | CNone := $(shell printf "\033[0m") 45 | 46 | # ==================================================================================== 47 | # Logger 48 | 49 | TIME_LONG = `date +%Y-%m-%d' '%H:%M:%S` 50 | TIME_SHORT = `date +%H:%M:%S` 51 | TIME = $(TIME_SHORT) 52 | 53 | INFO = echo ${TIME} ${BLUE}[ .. ]${CNone} 54 | WARN = echo ${TIME} ${YELLOW}[WARN]${CNone} 55 | ERR = echo ${TIME} ${RED}[FAIL]${CNone} 56 | OK = echo ${TIME} ${GREEN}[ OK ]${CNone} 57 | FAIL = (echo ${TIME} ${RED}[FAIL]${CNone} && false) 58 | 59 | # ==================================================================================== 60 | # Conformance 61 | 62 | # Ensure a PR is ready for review. 63 | reviewable: generate helm.generate 64 | @go mod tidy 65 | 66 | # Ensure branch is clean. 67 | check-diff: reviewable 68 | @$(INFO) checking that branch is clean 69 | @test -z "$$(git status --porcelain)" || (echo "$$(git status --porcelain)" && $(FAIL)) 70 | @$(OK) branch is clean 71 | 72 | # ==================================================================================== 73 | # Golang 74 | 75 | .PHONY: test 76 | test: generate lint ## Run tests 77 | @$(INFO) go test unit-tests 78 | go test -race -v ./... -coverprofile cover.out 79 | @$(OK) go test unit-tests 80 | 81 | .PHONY: build 82 | build: $(addprefix build-,$(ARCH)) 83 | 84 | .PHONY: build-% 85 | build-%: generate ## Build binary for the specified arch 86 | @$(INFO) go build $* 87 | @CGO_ENABLED=0 GOOS=linux GOARCH=$* \ 88 | go build -o '$(OUTPUT_DIR)/harbor-container-webhook-$*' ./main.go 89 | @$(OK) go build $* 90 | 91 | .PHONY: lint 92 | lint: ## run golangci-lint 93 | $(RUN_GOLANGCI_LINT) run 94 | 95 | fmt: ## ensure consistent code style 96 | @go mod tidy 97 | @go fmt ./... 98 | $(RUN_GOLANGCI_LINT) run --fix > /dev/null 2>&1 || true 99 | @$(OK) Ensured consistent code style 100 | 101 | generate: ## Generate code 102 | @go generate ./... 103 | 104 | # ==================================================================================== 105 | # Helm Chart 106 | 107 | helm.docs: ## Generate helm docs 108 | @cd $(HELM_DIR); \ 109 | docker run --rm -v $(shell pwd)/$(HELM_DIR):/helm-docs -u $(shell id -u) jnorwood/helm-docs:v1.5.0 110 | 111 | HELM_VERSION ?= $(shell helm show chart $(HELM_DIR) | grep 'version:' | sed 's/version: //g') 112 | 113 | helm.build: ## Build helm chart 114 | @$(INFO) helm package 115 | @helm package $(HELM_DIR) --dependency-update --destination $(OUTPUT_DIR)/chart 116 | @$(OK) helm package 117 | 118 | # ==================================================================================== 119 | # Build Artifacts 120 | 121 | build.all: docker.build helm.build 122 | 123 | docker.build: docker.buildx.setup ## Build the docker image 124 | @$(INFO) docker build 125 | @docker buildx build --platform $(DOCKER_BUILD_PLATFORMS) -t $(IMAGE_REGISTRY):$(VERSION) $(BUILD_ARGS) --push . 126 | @$(OK) docker build 127 | 128 | docker.buildx.setup: 129 | @$(INFO) docker buildx setup 130 | @docker buildx ls 2>/dev/null | grep -vq $(DOCKER_BUILDX_BUILDER) || docker buildx create --name $(DOCKER_BUILDX_BUILDER) --driver docker-container --driver-opt network=host --use 131 | @$(OK) docker buildx setup 132 | 133 | # ==================================================================================== 134 | # Local Testing 135 | 136 | hack/certs/tls.crt hack/certs/tls.key: 137 | hack/gencerts.sh 138 | 139 | .PHONY: hack 140 | hack: build hack/certs/tls.crt hack/certs/tls.key ## build and run the webhook w/hack config 141 | bin/harbor-container-webhook-* --config hack/config.yaml --kube-client-qps=5 --kube-client-burst=10 142 | 143 | .PHONY: hack-test 144 | hack-test: ## curl the admission and no-op json bodies to the webhook 145 | curl -X POST 'https://localhost:9443/webhook-v1-pod' --data-binary @hack/test/admission.json -H "Content-Type: application/json" --cert hack/certs/tls.crt --key hack/certs/tls.key --cacert hack/certs/caCert.pem 146 | curl -X POST 'https://localhost:9443/webhook-v1-pod' --data-binary @hack/test/no-op.json -H "Content-Type: application/json" --cert hack/certs/tls.crt --key hack/certs/tls.key --cacert hack/certs/caCert.pem 147 | 148 | # ==================================================================================== 149 | # Help 150 | 151 | # only comments after make target name are shown as help text 152 | help: ## displays this help message 153 | @echo -e "$$(grep -hE '^\S+:.*##' $(MAKEFILE_LIST) | sed -e 's/:.*##\s*/:/' -e 's/^\(.\+\):\(.*\)/\\x1b[36m\1\\x1b[m:\2/' | column -c2 -t -s : | sort)" 154 | -------------------------------------------------------------------------------- /OSSMETADATA: -------------------------------------------------------------------------------- 1 | osslifecycle=active 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | harbor-container-webhook 2 | ========= 3 | 4 | A kubernetes mutating webhook which rewrites container images to use a Harbor proxy cache. 5 | This is typically useful for mirroring public registries that have low rate limits, such as dockerhub, or limiting 6 | public bandwidth usage, by mirroring images in a local Harbor registry. 7 | 8 | harbor-container-webhook inspects pod requests in a kubernetes cluster and rewrites the container image registry of 9 | matching images. 10 | 11 | * [Prerequisites](#prerequisites) 12 | * [Installing](#installing) 13 | * [Usage](#usage) 14 | * [Local Development](#local-development) 15 | 16 | Prerequisites 17 | === 18 | Requires kubernetes 1.17+ and can either be installed via helm or bare manifests. 19 | 20 | Installing with helm 21 | === 22 | 23 | Option 1: Install from chart repository 24 | ```shell 25 | helm repo add harbor-container-webhook https://indeedeng.github.io/harbor-container-webhook/ 26 | 27 | helm install harbor-container-webhook harbor-container-webhook/harbor-container-webhook -n harbor-container-webhook --create-namespace 28 | ``` 29 | 30 | Option 2: Install chart from local build 31 | 32 | Build and install the Helm chart locally after cloning the repository. 33 | ```shell 34 | make helm.build 35 | 36 | helm install harbor-container-webhook ./bin/chart/harbor-container-webhook.tgz -n harbor-container-webhook --create-namespace 37 | ``` 38 | 39 | Usage 40 | === 41 | The harbor-container-webhook rewrites are managed by configuration rules. Each rule contains a list of regular 42 | expressions to match on, as well as an optional list of regular expressions to exclude. For each container image 43 | reference which matches at least one match rule and none of the exclusion rules, then the registry is replaced 44 | by the `replace` contents of the rule. If `checkUpstream` is enabled, the webhook will first fetch the manifest 45 | the rewritten container image reference and verify it exists before rewriting the image. 46 | 47 | Example configuration: 48 | ```yaml 49 | port: 9443 50 | certDir: "./hack/certs" 51 | healthAddr: ":8080" 52 | metricsAddr: ":8081" 53 | rules: 54 | - name: 'docker.io rewrite rule' 55 | # image refs must match at least one of the rules, and not match any excludes 56 | matches: 57 | - '^docker.io' 58 | excludes: 59 | # for example, exclude ubuntu from harbor's proxy cache 60 | - '^docker.io/(library/)?ubuntu:.*$' 61 | replace: 'harbor.example.com/dockerhub-proxy' 62 | checkUpstream: false 63 | - name: 'docker.io ubuntu rewrite rule' 64 | # image refs must match at least one of the rules, and not match any excludes 65 | matches: 66 | - '^docker.io/(library/)?ubuntu:.*$' 67 | replace: 'harbor.example.com/ubuntu-proxy' 68 | checkUpstream: true # tests if the manifest for the rewritten image exists 69 | authSecretName: harbor-example-image-pull-secret # optional, defaults to "" - secret in the webhook namespace for authenticating to harbor.example.com 70 | ``` 71 | Local Development 72 | === 73 | `make help` prints out the help info for local development: 74 | 75 | ``` 76 | build Build binary for the specified arch 77 | docker.build Build the docker image 78 | fmt ensure consistent code style 79 | generate Generate code 80 | hack-test curl the admission and no-op json bodies to the webhook 81 | hack build and run the webhook w/hack config 82 | helm.build Build helm chart 83 | helm.docs Generate helm docs 84 | help displays this help message 85 | lint run golangci-lint 86 | test Run tests 87 | ``` 88 | 89 | Ensure tests and linters pass with `make lint test`. 90 | 91 | The webhook can be run locally with `make hack` and then `make hack-test` to submit sample responses to the webhook. 92 | 93 | Contributing 94 | === 95 | We welcome contributions! Feel free to help make the harbor-container-webhook better. 96 | 97 | Code of Conduct 98 | === 99 | harbor-container-webhook is governed by the [Contributer Covenant v1.4.1](CODE_OF_CONDUCT.md) 100 | 101 | For more information please contact opensource@indeed.com. 102 | 103 | License 104 | === 105 | The harbor-container-webhook is open source under the [Apache 2](LICENSE) license. 106 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | TODO: Migrate these to github actions. 4 | 5 | ### Prerelease 6 | 7 | 1. Ensure the [CHANGELOG.md](CHANGELOG.md) is up to date. 8 | 2. Bump the [Chart.yaml](deploy/charts/harbor-container-webhook/Chart.yaml) `version` or `appVersion` as needed. 9 | 10 | ### Test the build of harbor-container-webhook 11 | 12 | 1. Build the webhook: `make docker.build` 13 | 14 | ### Release harbor-container-webhook 15 | 16 | 1. Release the webhook: `git tag 0.x.x && git push --tags` 17 | 18 | ### Release Helm Chart 19 | 20 | 1. Regenerate the helm chart + docs: `helm.build` -------------------------------------------------------------------------------- /config/certmanager/certificate.yaml: -------------------------------------------------------------------------------- 1 | # The following manifests contain a self-signed issuer CR and a certificate CR. 2 | # More document can be found at https://docs.cert-manager.io 3 | # WARNING: Targets CertManager 0.11 check https://docs.cert-manager.io/en/latest/tasks/upgrading/index.html for 4 | # breaking changes 5 | apiVersion: cert-manager.io/v1alpha2 6 | kind: Issuer 7 | metadata: 8 | name: selfsigned-issuer 9 | namespace: system 10 | spec: 11 | selfSigned: {} 12 | --- 13 | apiVersion: cert-manager.io/v1alpha2 14 | kind: Certificate 15 | metadata: 16 | name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml 17 | namespace: system 18 | spec: 19 | # $(SERVICE_NAME) and $(SERVICE_NAMESPACE) will be substituted by kustomize 20 | dnsNames: 21 | - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc 22 | - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc.cluster.local 23 | issuerRef: 24 | kind: Issuer 25 | name: selfsigned-issuer 26 | secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize 27 | -------------------------------------------------------------------------------- /config/certmanager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - certificate.yaml 3 | 4 | configurations: 5 | - kustomizeconfig.yaml 6 | -------------------------------------------------------------------------------- /config/certmanager/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This configuration is for teaching kustomize how to update name ref and var substitution 2 | nameReference: 3 | - kind: Issuer 4 | group: cert-manager.io 5 | fieldSpecs: 6 | - kind: Certificate 7 | group: cert-manager.io 8 | path: spec/issuerRef/name 9 | 10 | varReference: 11 | - kind: Certificate 12 | group: cert-manager.io 13 | path: spec/commonName 14 | - kind: Certificate 15 | group: cert-manager.io 16 | path: spec/dnsNames 17 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: harbor-container-webhook-system 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: harbor-container-webhook- 10 | 11 | # Labels to add to all resources and selectors. 12 | #commonLabels: 13 | # someName: someValue 14 | 15 | bases: 16 | - ../crd 17 | - ../rbac 18 | - ../manager 19 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 20 | # crd/kustomization.yaml 21 | #- ../webhook 22 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 23 | #- ../certmanager 24 | # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. 25 | #- ../prometheus 26 | 27 | patchesStrategicMerge: 28 | # Protect the /metrics endpoint by putting it behind auth. 29 | # If you want your controller-manager to expose the /metrics 30 | # endpoint w/o any authn/z, please comment the following line. 31 | - manager_auth_proxy_patch.yaml 32 | 33 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 34 | # crd/kustomization.yaml 35 | #- manager_webhook_patch.yaml 36 | 37 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 38 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 39 | # 'CERTMANAGER' needs to be enabled to use ca injection 40 | #- webhookcainjection_patch.yaml 41 | 42 | # the following config is for teaching kustomize how to do var substitution 43 | vars: 44 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 45 | #- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR 46 | # objref: 47 | # kind: Certificate 48 | # group: cert-manager.io 49 | # version: v1alpha2 50 | # name: serving-cert # this name should match the one in certificate.yaml 51 | # fieldref: 52 | # fieldpath: metadata.namespace 53 | #- name: CERTIFICATE_NAME 54 | # objref: 55 | # kind: Certificate 56 | # group: cert-manager.io 57 | # version: v1alpha2 58 | # name: serving-cert # this name should match the one in certificate.yaml 59 | #- name: SERVICE_NAMESPACE # namespace of the service 60 | # objref: 61 | # kind: Service 62 | # version: v1 63 | # name: webhook-service 64 | # fieldref: 65 | # fieldpath: metadata.namespace 66 | #- name: SERVICE_NAME 67 | # objref: 68 | # kind: Service 69 | # version: v1 70 | # name: webhook-service 71 | -------------------------------------------------------------------------------- /config/default/manager_auth_proxy_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch inject a sidecar container which is a HTTP proxy for the 2 | # controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: controller-manager 7 | namespace: system 8 | spec: 9 | template: 10 | spec: 11 | containers: 12 | - name: kube-rbac-proxy 13 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.5.0 14 | args: 15 | - "--secure-listen-address=0.0.0.0:8443" 16 | - "--upstream=http://127.0.0.1:8080/" 17 | - "--logtostderr=true" 18 | - "--v=10" 19 | ports: 20 | - containerPort: 8443 21 | name: https 22 | - name: manager 23 | args: 24 | - "--metrics-addr=127.0.0.1:8080" 25 | - "--enable-leader-election" 26 | -------------------------------------------------------------------------------- /config/default/manager_webhook_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: manager 11 | ports: 12 | - containerPort: 9443 13 | name: webhook-server 14 | protocol: TCP 15 | volumeMounts: 16 | - mountPath: /tmp/k8s-webhook-server/serving-certs 17 | name: cert 18 | readOnly: true 19 | volumes: 20 | - name: cert 21 | secret: 22 | defaultMode: 420 23 | secretName: webhook-server-cert 24 | -------------------------------------------------------------------------------- /config/default/webhookcainjection_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch add annotation to admission webhook config and 2 | # the variables $(CERTIFICATE_NAMESPACE) and $(CERTIFICATE_NAME) will be substituted by kustomize. 3 | apiVersion: admissionregistration.k8s.io/v1beta1 4 | kind: MutatingWebhookConfiguration 5 | metadata: 6 | name: mutating-webhook-configuration 7 | annotations: 8 | cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 9 | --- 10 | apiVersion: admissionregistration.k8s.io/v1beta1 11 | kind: ValidatingWebhookConfiguration 12 | metadata: 13 | name: validating-webhook-configuration 14 | annotations: 15 | cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 16 | -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: system 7 | --- 8 | apiVersion: apps/v1 9 | kind: Deployment 10 | metadata: 11 | name: controller-manager 12 | namespace: system 13 | labels: 14 | control-plane: controller-manager 15 | spec: 16 | selector: 17 | matchLabels: 18 | control-plane: controller-manager 19 | replicas: 1 20 | template: 21 | metadata: 22 | labels: 23 | control-plane: controller-manager 24 | spec: 25 | containers: 26 | - command: 27 | - /harbor-container-webhook 28 | args: 29 | - --enable-leader-election 30 | - --harbor-addr 31 | - harbor.example.com 32 | image: controller:latest 33 | name: manager 34 | resources: 35 | limits: 36 | cpu: 100m 37 | memory: 30Mi 38 | requests: 39 | cpu: 100m 40 | memory: 20Mi 41 | terminationGracePeriodSeconds: 10 42 | -------------------------------------------------------------------------------- /config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | -------------------------------------------------------------------------------- /config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Prometheus Monitor Service (Metrics) 3 | apiVersion: monitoring.coreos.com/v1 4 | kind: ServiceMonitor 5 | metadata: 6 | labels: 7 | control-plane: controller-manager 8 | name: controller-manager-metrics-monitor 9 | namespace: system 10 | spec: 11 | endpoints: 12 | - path: /metrics 13 | port: https 14 | selector: 15 | matchLabels: 16 | control-plane: controller-manager 17 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_client_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1beta1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-reader 5 | rules: 6 | - nonResourceURLs: ["/metrics"] 7 | verbs: ["get"] 8 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: proxy-role 5 | rules: 6 | - apiGroups: ["authentication.k8s.io"] 7 | resources: 8 | - tokenreviews 9 | verbs: ["create"] 10 | - apiGroups: ["authorization.k8s.io"] 11 | resources: 12 | - subjectaccessreviews 13 | verbs: ["create"] 14 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: proxy-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: proxy-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: controller-manager-metrics-service 7 | namespace: system 8 | spec: 9 | ports: 10 | - name: https 11 | port: 8443 12 | targetPort: https 13 | selector: 14 | control-plane: controller-manager 15 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - role.yaml 3 | - role_binding.yaml 4 | - leader_election_role.yaml 5 | - leader_election_role_binding.yaml 6 | # Comment the following 4 lines if you want to disable 7 | # the auth proxy (https://github.com/brancz/kube-rbac-proxy) 8 | # which protects your /metrics endpoint. 9 | - auth_proxy_service.yaml 10 | - auth_proxy_role.yaml 11 | - auth_proxy_role_binding.yaml 12 | - auth_proxy_client_clusterrole.yaml 13 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | name: leader-election-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - configmaps 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - create 16 | - update 17 | - patch 18 | - delete 19 | - apiGroups: 20 | - "" 21 | resources: 22 | - configmaps/status 23 | verbs: 24 | - get 25 | - update 26 | - patch 27 | - apiGroups: 28 | - "" 29 | resources: 30 | - events 31 | verbs: 32 | - create 33 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: leader-election-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: Role 8 | name: leader-election-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: manager-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: manager-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: default 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/webhook/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manifests.yaml 3 | - service.yaml 4 | 5 | configurations: 6 | - kustomizeconfig.yaml 7 | -------------------------------------------------------------------------------- /config/webhook/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # the following config is for teaching kustomize where to look at when substituting vars. 2 | # It requires kustomize v2.1.0 or newer to work properly. 3 | nameReference: 4 | - kind: Service 5 | version: v1 6 | fieldSpecs: 7 | - kind: MutatingWebhookConfiguration 8 | group: admissionregistration.k8s.io 9 | path: webhooks/clientConfig/service/name 10 | - kind: ValidatingWebhookConfiguration 11 | group: admissionregistration.k8s.io 12 | path: webhooks/clientConfig/service/name 13 | 14 | namespace: 15 | - kind: MutatingWebhookConfiguration 16 | group: admissionregistration.k8s.io 17 | path: webhooks/clientConfig/service/namespace 18 | create: true 19 | - kind: ValidatingWebhookConfiguration 20 | group: admissionregistration.k8s.io 21 | path: webhooks/clientConfig/service/namespace 22 | create: true 23 | 24 | varReference: 25 | - path: metadata/annotations 26 | -------------------------------------------------------------------------------- /config/webhook/manifests.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | apiVersion: admissionregistration.k8s.io/v1beta1 4 | kind: MutatingWebhookConfiguration 5 | metadata: 6 | creationTimestamp: null 7 | name: mutating-webhook-configuration 8 | webhooks: 9 | - clientConfig: 10 | caBundle: Cg== 11 | service: 12 | name: webhook-service 13 | namespace: system 14 | path: /webhook-v1-pod 15 | failurePolicy: Fail 16 | name: mpod.kb.io 17 | rules: 18 | - apiGroups: 19 | - "" 20 | apiVersions: 21 | - v1 22 | operations: 23 | - CREATE 24 | - UPDATE 25 | resources: 26 | - pods 27 | -------------------------------------------------------------------------------- /config/webhook/service.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: webhook-service 6 | namespace: system 7 | spec: 8 | ports: 9 | - port: 443 10 | targetPort: 9443 11 | selector: 12 | control-plane: controller-manager 13 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/.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 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: harbor-container-webhook 3 | description: Webhook to configure pods with harbor proxy cache projects 4 | type: application 5 | version: 0.8.1 6 | appVersion: "0.8.0" 7 | kubeVersion: ">= 1.16.0-0" 8 | home: https://github.com/IndeedEng/harbor-container-webhook 9 | maintainers: 10 | - name: cnmcavoy 11 | email: cmcavoy@indeed.com 12 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/README.md: -------------------------------------------------------------------------------- 1 | # harbor-container-webhook 2 | 3 | ![Version: 0.8.0](https://img.shields.io/badge/Version-0.8.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.8.0](https://img.shields.io/badge/AppVersion-0.8.0-informational?style=flat-square) 4 | 5 | Webhook to configure pods with harbor proxy cache projects 6 | 7 | **Homepage:** 8 | 9 | ## Maintainers 10 | 11 | | Name | Email | Url | 12 | | ---- | ------ | --- | 13 | | cnmcavoy | cmcavoy@indeed.com | | 14 | 15 | ## Requirements 16 | 17 | Kubernetes: `>= 1.16.0-0` 18 | 19 | ## Values 20 | 21 | | Key | Type | Default | Description | 22 | |-----|------|---------|-------------| 23 | | additionalVolumeMounts | list | `[]` | | 24 | | additionalVolumes | list | `[]` | | 25 | | affinity | object | `{}` | | 26 | | certDir | string | `""` | | 27 | | certManager.apiVersion | string | `"cert-manager.io/v1"` | | 28 | | certManager.duration | string | `"2160h0m0s"` | | 29 | | certManager.enabled | bool | `true` | | 30 | | certManager.renewBefore | string | `"360h0m0s"` | | 31 | | extraArgs | list | `[]` | | 32 | | extraEnv | list | `[]` | | 33 | | extraRules | list | `[]` | | 34 | | fullnameOverride | string | `""` | | 35 | | healthPort | int | `8090` | | 36 | | image.pullPolicy | string | `"IfNotPresent"` | | 37 | | image.repository | string | `"ghcr.io/indeedeng/harbor-container-webhook"` | | 38 | | image.tag | string | `"main"` | | 39 | | imagePullSecrets | list | `[]` | | 40 | | initContainers | list | `[]` | | 41 | | metrics.serviceMonitor.enabled | bool | `false` | | 42 | | metrics.serviceMonitor.honorLabels | bool | `false` | | 43 | | metrics.serviceMonitor.interval | string | `""` | | 44 | | metrics.serviceMonitor.jobLabel | string | `""` | | 45 | | metrics.serviceMonitor.labels | object | `{}` | | 46 | | metrics.serviceMonitor.metricRelabelings | list | `[]` | | 47 | | metrics.serviceMonitor.relabelings | list | `[]` | | 48 | | metrics.serviceMonitor.scheme | string | `"http"` | | 49 | | metrics.serviceMonitor.scrapeTimeout | string | `""` | | 50 | | minAvailable | int | `1` | Minimum available pods set in PodDisruptionBudget. Define either 'minAvailable' or 'maxUnavailable', never both. | 51 | | nameOverride | string | `""` | | 52 | | nodeSelector | object | `{}` | | 53 | | podAnnotations | object | `{}` | | 54 | | podSecurityContext | object | `{}` | | 55 | | priorityClassName | string | `""` | | 56 | | prometheus.enabled | bool | `true` | | 57 | | prometheus.port | int | `8080` | | 58 | | replicaCount | int | `1` | | 59 | | resources | object | `{}` | | 60 | | rules | list | `[]` | | 61 | | securityContext.capabilities.drop[0] | string | `"ALL"` | | 62 | | securityContext.readOnlyRootFilesystem | bool | `true` | | 63 | | securityContext.runAsNonRoot | bool | `true` | | 64 | | securityContext.runAsUser | int | `65532` | | 65 | | service.port | int | `9443` | | 66 | | service.type | string | `"ClusterIP"` | | 67 | | serviceAccount.annotations | object | `{}` | | 68 | | serviceAccount.create | bool | `true` | | 69 | | serviceAccount.name | string | `""` | | 70 | | tolerations | list | `[]` | | 71 | | unhealthyPodEvictionPolicy | string | `""` | Maximum unavailable pods set in PodDisruptionBudget. If set, 'minAvailable' is ignored. maxUnavailable: 1 -- Eviction policy for unhealthy pods guarded by PodDisruptionBudget. Ref: https://kubernetes.io/blog/2023/01/06/unhealthy-pod-eviction-policy-for-pdbs/ | 72 | | verbose | bool | `false` | | 73 | | webhook.failurePolicy | string | `"Ignore"` | | 74 | | webhook.namespaceSelector.matchExpressions[0].key | string | `"goharbor.io/harbor-container-webhook-disable"` | | 75 | | webhook.namespaceSelector.matchExpressions[0].operator | string | `"NotIn"` | | 76 | | webhook.namespaceSelector.matchExpressions[0].values[0] | string | `"true"` | | 77 | | webhook.objectSelector.matchExpressions[0].key | string | `"goharbor.io/harbor-container-webhook-disable"` | | 78 | | webhook.objectSelector.matchExpressions[0].operator | string | `"NotIn"` | | 79 | | webhook.objectSelector.matchExpressions[0].values[0] | string | `"true"` | | 80 | 81 | ---------------------------------------------- 82 | Autogenerated from chart metadata using [helm-docs v1.5.0](https://github.com/norwoodj/helm-docs/releases/v1.5.0) 83 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/NOTES.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indeedeng/harbor-container-webhook/c21732bcc7624651e3bb5073583008ab90847bfd/deploy/charts/harbor-container-webhook/templates/NOTES.txt -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "harbor-container-webhook.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 7 | {{- end }} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "harbor-container-webhook.fullname" -}} 15 | {{- if .Values.fullnameOverride }} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 17 | {{- else }} 18 | {{- $name := default .Chart.Name .Values.nameOverride }} 19 | {{- if contains $name .Release.Name }} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 21 | {{- else }} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 23 | {{- end }} 24 | {{- end }} 25 | {{- end }} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "harbor-container-webhook.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 32 | {{- end }} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "harbor-container-webhook.labels" -}} 38 | helm.sh/chart: {{ include "harbor-container-webhook.chart" . }} 39 | {{ include "harbor-container-webhook.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end }} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "harbor-container-webhook.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "harbor-container-webhook.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end }} 53 | 54 | {{/* 55 | Create the name of the service account to use 56 | */}} 57 | {{- define "harbor-container-webhook.serviceAccountName" -}} 58 | {{- if .Values.serviceAccount.create }} 59 | {{- default (include "harbor-container-webhook.fullname" .) .Values.serviceAccount.name }} 60 | {{- else }} 61 | {{- default "default" .Values.serviceAccount.name }} 62 | {{- end }} 63 | {{- end }} 64 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/certificate.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.certManager.enabled }} 2 | apiVersion: {{ .Values.certManager.apiVersion }} 3 | kind: Certificate 4 | metadata: 5 | name: {{ include "harbor-container-webhook.fullname" . }} 6 | labels: 7 | {{- include "harbor-container-webhook.labels" . | nindent 4 }} 8 | namespace: {{ .Release.Namespace }} 9 | spec: 10 | secretName: {{ include "harbor-container-webhook.fullname" . }}-certs 11 | duration: {{ .Values.certManager.duration }} 12 | renewBefore: {{ .Values.certManager.renewBefore }} 13 | commonName: {{ include "harbor-container-webhook.fullname" . }}.{{ .Release.Namespace }}.svc 14 | dnsNames: 15 | - {{ include "harbor-container-webhook.fullname" . }} 16 | - {{ include "harbor-container-webhook.fullname" . }}.{{ .Release.Namespace }} 17 | - {{ include "harbor-container-webhook.fullname" . }}.{{ .Release.Namespace }}.svc 18 | issuerRef: 19 | kind: Issuer 20 | name: {{ include "harbor-container-webhook.fullname" . }}-self-signed-issuer 21 | --- 22 | apiVersion: {{ .Values.certManager.apiVersion }} 23 | kind: Issuer 24 | metadata: 25 | name: {{ include "harbor-container-webhook.fullname" . }}-self-signed-issuer 26 | labels: 27 | {{- include "harbor-container-webhook.labels" . | nindent 4 }} 28 | spec: 29 | selfSigned: {} 30 | {{- end }} 31 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "harbor-container-webhook.fullname" . }} 5 | labels: 6 | {{- include "harbor-container-webhook.selectorLabels" . | nindent 4 }} 7 | namespace: {{ .Release.Namespace }} 8 | data: 9 | webhook-config.yaml: | 10 | port: {{ .Values.service.port }} 11 | {{- if .Values.certManager.enabled }} 12 | certDir: "/etc/hcw/certs" 13 | {{- else }} 14 | certDir: "{{ .Values.certDir }}" 15 | {{- end }} 16 | {{- if .Values.prometheus.enabled }} 17 | metricsAddr: ":{{ .Values.prometheus.port }}" 18 | {{- else }} 19 | metricsAddr: "0" 20 | {{- end }} 21 | healthAddr: ":{{ .Values.healthPort }}" 22 | verbose: {{ .Values.verbose }} 23 | rules: 24 | {{- concat (default list .Values.rules) (default list .Values.extraRules) | toYaml | nindent 6 }} 25 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "harbor-container-webhook.fullname" . }} 5 | labels: 6 | {{- include "harbor-container-webhook.labels" . | nindent 4 }} 7 | namespace: {{ .Release.Namespace }} 8 | spec: 9 | replicas: {{ .Values.replicaCount }} 10 | selector: 11 | matchLabels: 12 | {{- include "harbor-container-webhook.selectorLabels" . | nindent 6 }} 13 | template: 14 | metadata: 15 | annotations: 16 | {{- if .Values.podAnnotations }} 17 | {{- toYaml .Values.podAnnotations | nindent 8 }} 18 | {{- end }} 19 | checksum/configmap: {{ include (print $.Template.BasePath "/config.yaml") . | sha256sum }} 20 | labels: 21 | {{- include "harbor-container-webhook.selectorLabels" . | nindent 8 }} 22 | goharbor.io/harbor-container-webhook-disable: "true" 23 | spec: 24 | {{- with .Values.imagePullSecrets }} 25 | imagePullSecrets: 26 | {{- toYaml . | nindent 8 }} 27 | {{- end }} 28 | serviceAccountName: {{ include "harbor-container-webhook.serviceAccountName" . }} 29 | priorityClassName: {{ .Values.priorityClassName }} 30 | securityContext: 31 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 32 | {{- if .Values.initContainers }} 33 | initContainers: 34 | {{- toYaml .Values.initContainers | nindent 8 }} 35 | {{- end }} 36 | containers: 37 | - name: {{ .Chart.Name }} 38 | args: 39 | - --config=/etc/hcw/config/webhook-config.yaml 40 | {{- range $arg := .Values.extraArgs }} 41 | - {{ $arg }} 42 | {{- end }} 43 | {{- with .Values.extraEnv }} 44 | env: 45 | - name: POD_NAMESPACE 46 | valueFrom: 47 | fieldRef: 48 | fieldPath: metadata.namespace 49 | {{- toYaml . | nindent 12 }} 50 | {{- end }} 51 | securityContext: 52 | {{- toYaml .Values.securityContext | nindent 12 }} 53 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 54 | imagePullPolicy: {{ .Values.image.pullPolicy }} 55 | ports: 56 | - name: https 57 | containerPort: {{ .Values.service.port }} 58 | protocol: TCP 59 | - name: metrics 60 | containerPort: 8080 61 | protocol: TCP 62 | livenessProbe: 63 | httpGet: 64 | path: /healthz 65 | port: {{ .Values.healthPort }} 66 | readinessProbe: 67 | httpGet: 68 | path: /readyz 69 | port: {{ .Values.healthPort }} 70 | resources: 71 | {{- toYaml .Values.resources | nindent 12 }} 72 | volumeMounts: 73 | - mountPath: /etc/hcw/config 74 | name: config 75 | readOnly: true 76 | {{- if .Values.certManager.enabled }} 77 | - mountPath: /etc/hcw/certs 78 | name: certs 79 | readOnly: true 80 | {{- end }} 81 | {{- with .Values.additionalVolumeMounts }} 82 | {{- toYaml . | nindent 12 }} 83 | {{- end }} 84 | volumes: 85 | - name: config 86 | configMap: 87 | name: {{ include "harbor-container-webhook.fullname" . }} 88 | {{- if .Values.certManager.enabled }} 89 | - name: certs 90 | secret: 91 | secretName: {{ include "harbor-container-webhook.fullname" . }}-certs 92 | {{- end }} 93 | {{- with .Values.additionalVolumes }} 94 | {{- toYaml . | nindent 8 }} 95 | {{- end }} 96 | {{- with .Values.nodeSelector }} 97 | nodeSelector: 98 | {{- toYaml . | nindent 8 }} 99 | {{- end }} 100 | {{- with .Values.affinity }} 101 | affinity: 102 | {{- toYaml . | nindent 8 }} 103 | {{- end }} 104 | {{- with .Values.tolerations }} 105 | tolerations: 106 | {{- toYaml . | nindent 8 }} 107 | {{- end }} 108 | {{- with .Values.topologySpreadConstraints }} 109 | topologySpreadConstraints: 110 | {{- toYaml . | nindent 8 }} 111 | {{- end }} 112 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/poddisruptionbudget.yaml: -------------------------------------------------------------------------------- 1 | {{- if gt (int .Values.replicaCount) 1 }} 2 | apiVersion: policy/v1 3 | kind: PodDisruptionBudget 4 | metadata: 5 | labels: 6 | {{- include "harbor-container-webhook.labels" . | nindent 4 }} 7 | name: {{ include "harbor-container-webhook.fullname" . }} 8 | namespace: {{ .Release.Namespace }} 9 | spec: 10 | selector: 11 | matchLabels: 12 | {{- include "harbor-container-webhook.selectorLabels" . | nindent 6 }} 13 | {{- if and .Values.minAvailable (not (hasKey .Values "maxUnavailable")) }} 14 | minAvailable: {{ .Values.minAvailable }} 15 | {{- else if .Values.maxUnavailable }} 16 | maxUnavailable: {{ .Values.maxUnavailable }} 17 | {{- end }} 18 | {{- if .Values.unhealthyPodEvictionPolicy }} 19 | unhealthyPodEvictionPolicy: {{ .Values.unhealthyPodEvictionPolicy }} 20 | {{- end }} 21 | {{ end }} -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | kind: ClusterRole 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | metadata: 4 | name: {{ include "harbor-container-webhook.fullname" . }} 5 | rules: 6 | - apiGroups: [""] 7 | resources: 8 | - nodes 9 | verbs: 10 | - get 11 | - list 12 | - watch 13 | - apiGroups: [""] 14 | resources: 15 | - secrets 16 | verbs: 17 | - get 18 | - list 19 | - watch 20 | --- 21 | kind: ClusterRoleBinding 22 | apiVersion: rbac.authorization.k8s.io/v1 23 | metadata: 24 | name: {{ include "harbor-container-webhook.fullname" . }} 25 | roleRef: 26 | apiGroup: rbac.authorization.k8s.io 27 | kind: ClusterRole 28 | name: {{ include "harbor-container-webhook.fullname" . }} 29 | subjects: 30 | - kind: ServiceAccount 31 | name: {{ include "harbor-container-webhook.serviceAccountName" . }} 32 | namespace: {{ .Release.Namespace }} 33 | --- -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "harbor-container-webhook.fullname" . }} 5 | labels: 6 | {{- include "harbor-container-webhook.labels" . | nindent 4 }} 7 | namespace: {{ .Release.Namespace }} 8 | spec: 9 | type: {{ .Values.service.type }} 10 | ports: 11 | - port: 443 12 | protocol: TCP 13 | targetPort: https 14 | name: https 15 | - port: 8080 16 | protocol: TCP 17 | targetPort: metrics 18 | name: prometheus 19 | selector: 20 | {{- include "harbor-container-webhook.selectorLabels" . | nindent 4 }} 21 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create }} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "harbor-container-webhook.serviceAccountName" . }} 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | {{- include "harbor-container-webhook.labels" . | nindent 4 }} 9 | {{- with .Values.serviceAccount.annotations }} 10 | annotations: 11 | {{- toYaml . | nindent 4 }} 12 | {{- end }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.metrics.serviceMonitor.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | name: {{ include "harbor-container-webhook.fullname" . }} 6 | labels: 7 | {{- include "harbor-container-webhook.labels" . | nindent 4 }} 8 | {{- if .Values.metrics.serviceMonitor.labels }} 9 | {{- toYaml .Values.metrics.serviceMonitor.labels | nindent 4 }} 10 | {{- end }} 11 | namespace: {{ .Release.Namespace }} 12 | spec: 13 | {{- if .Values.metrics.serviceMonitor.jobLabel }} 14 | jobLabel: {{ .Values.metrics.serviceMonitor.jobLabel }} 15 | {{- end }} 16 | selector: 17 | matchLabels: 18 | {{- include "harbor-container-webhook.selectorLabels" . | nindent 6 }} 19 | endpoints: 20 | - port: prometheus 21 | path: /metrics 22 | scheme: {{ .Values.metrics.serviceMonitor.scheme }} 23 | {{- if .Values.metrics.serviceMonitor.interval }} 24 | interval: {{ .Values.metrics.serviceMonitor.interval }} 25 | {{- end }} 26 | {{- if .Values.metrics.serviceMonitor.scrapeTimeout }} 27 | scrapeTimeout: {{ .Values.metrics.serviceMonitor.scrapeTimeout }} 28 | {{- end }} 29 | {{- if .Values.metrics.serviceMonitor.honorLabels }} 30 | honorLabels: {{ .Values.metrics.serviceMonitor.honorLabels }} 31 | {{- end }} 32 | {{- if .Values.metrics.serviceMonitor.metricRelabelings }} 33 | metricRelabelings: {{ .Values.metrics.serviceMonitor.metricRelabelings }} 34 | {{- end }} 35 | {{- if .Values.metrics.serviceMonitor.relabelings }} 36 | relabelings: {{ .Values.metrics.serviceMonitor.relabelings }} 37 | {{- end }} 38 | namespaceSelector: 39 | matchNames: 40 | - {{ .Release.Namespace }} 41 | {{- end }} 42 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/templates/webhook.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: admissionregistration.k8s.io/v1 2 | kind: MutatingWebhookConfiguration 3 | metadata: 4 | name: {{ include "harbor-container-webhook.fullname" . }} 5 | {{- if .Values.certManager.enabled }} 6 | annotations: 7 | cert-manager.io/inject-ca-from: "{{ .Release.Namespace }}/{{ include "harbor-container-webhook.fullname" . }}" 8 | {{- end }} 9 | namespace: {{ .Release.Namespace }} 10 | webhooks: 11 | - name: {{ include "harbor-container-webhook.fullname" . }}.{{ .Release.Namespace }}.svc 12 | sideEffects: None 13 | matchPolicy: Equivalent 14 | reinvocationPolicy: IfNeeded 15 | admissionReviewVersions: 16 | - v1beta1 17 | rules: 18 | - apiGroups: 19 | - "" 20 | apiVersions: 21 | - "v1" 22 | operations: 23 | - CREATE 24 | - UPDATE 25 | resources: 26 | - pods 27 | failurePolicy: {{ .Values.webhook.failurePolicy }} 28 | clientConfig: 29 | service: 30 | name: {{ include "harbor-container-webhook.fullname" . }} 31 | namespace: {{ .Release.Namespace }} 32 | path: "/webhook-v1-pod" 33 | {{- if .Values.webhook.namespaceSelector }} 34 | namespaceSelector: 35 | {{- .Values.webhook.namespaceSelector | toYaml | nindent 6 }} 36 | {{- end }} 37 | {{- if .Values.webhook.objectSelector }} 38 | objectSelector: 39 | {{- .Values.webhook.objectSelector | toYaml | nindent 6 }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /deploy/charts/harbor-container-webhook/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for harbor-container-webhook. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | replicaCount: 1 6 | 7 | # -- Minimum available pods set in PodDisruptionBudget. 8 | # Define either 'minAvailable' or 'maxUnavailable', never both. 9 | minAvailable: 1 10 | # -- Maximum unavailable pods set in PodDisruptionBudget. If set, 'minAvailable' is ignored. 11 | # maxUnavailable: 1 12 | # -- Eviction policy for unhealthy pods guarded by PodDisruptionBudget. 13 | # Ref: https://kubernetes.io/blog/2023/01/06/unhealthy-pod-eviction-policy-for-pdbs/ 14 | unhealthyPodEvictionPolicy: "" 15 | 16 | image: 17 | repository: ghcr.io/indeedeng/harbor-container-webhook 18 | pullPolicy: IfNotPresent 19 | tag: "main" 20 | 21 | imagePullSecrets: [] 22 | nameOverride: "" 23 | fullnameOverride: "" 24 | 25 | serviceAccount: 26 | # Specifies whether a service account should be created 27 | create: true 28 | # Annotations to add to the service account 29 | annotations: {} 30 | # The name of the service account to use. 31 | # If not set and create is true, a name is generated using the fullname template 32 | name: "" 33 | 34 | podAnnotations: {} 35 | 36 | podSecurityContext: {} 37 | 38 | securityContext: 39 | capabilities: 40 | drop: 41 | - ALL 42 | readOnlyRootFilesystem: true 43 | runAsNonRoot: true 44 | runAsUser: 65532 45 | 46 | service: 47 | type: ClusterIP 48 | port: 9443 49 | 50 | additionalVolumeMounts: [] 51 | 52 | additionalVolumes: [] 53 | 54 | initContainers: [] 55 | 56 | resources: {} 57 | 58 | nodeSelector: {} 59 | 60 | tolerations: [] 61 | 62 | affinity: {} 63 | 64 | topologySpreadConstraints: {} 65 | 66 | # only use one of the following two options for the pdb, if replicas > 1: 67 | # minAvailable: 1 68 | # maxUnavailable: 1 69 | 70 | # unhealthyPodEvictionPolicy: "" 71 | 72 | extraArgs: [] 73 | extraEnv: [] 74 | 75 | priorityClassName: "" 76 | 77 | certManager: 78 | enabled: true 79 | apiVersion: "cert-manager.io/v1" 80 | duration: 2160h0m0s 81 | renewBefore: 360h0m0s 82 | 83 | webhook: 84 | namespaceSelector: 85 | matchExpressions: 86 | - key: "goharbor.io/harbor-container-webhook-disable" 87 | operator: NotIn 88 | values: ["true"] 89 | objectSelector: 90 | matchExpressions: 91 | - key: "goharbor.io/harbor-container-webhook-disable" 92 | operator: NotIn 93 | values: ["true"] 94 | failurePolicy: Ignore 95 | 96 | ## configures the webhook rules, which are evaluated for each image in a pod 97 | rules: [] 98 | # - name: 'docker.io rewrite rule' 99 | # # image refs must match at least one of the rules, and not match any excludes 100 | # matches: 101 | # - '^docker.io' 102 | # excludes: 103 | # # for example, exclude ubuntu from harbor's proxy cache 104 | # - '^docker.io/(library/)?ubuntu:.*$' 105 | # replace: 'harbor.example.com/dockerhub-proxy' 106 | # checkUpstream: false 107 | # - name: 'docker.io ubuntu rewrite rule' 108 | # # image refs must match at least one of the rules, and not match any excludes 109 | # matches: 110 | # - '^docker.io/(library/)?ubuntu:.*$' 111 | # replace: 'harbor.example.com/ubuntu-proxy' 112 | # checkUpstream: true # tests if the manifest for the rewritten image exists 113 | # platforms: # defaults to linux/amd64, only used if checkUpstream is set 114 | # - linux/amd64 115 | # - linux/arm64 116 | 117 | extraRules: [] 118 | 119 | certDir: "" 120 | prometheus: 121 | enabled: true 122 | port: 8080 123 | healthPort: 8090 124 | verbose: false 125 | 126 | metrics: 127 | serviceMonitor: 128 | enabled: false 129 | jobLabel: "" 130 | interval: "" 131 | scrapeTimeout: "" 132 | honorLabels: false 133 | metricRelabelings: [] 134 | relabelings: [] 135 | labels: {} 136 | scheme: http 137 | -------------------------------------------------------------------------------- /docs/example-config/dockerhub.yaml: -------------------------------------------------------------------------------- 1 | port: 9443 2 | certDir: "./hack/certs" 3 | healthAddr: ":8080" 4 | metricsAddr: ":8081" 5 | rules: 6 | - name: 'docker.io rewrite rule' 7 | matches: 8 | - '^docker.io' 9 | excludes: [] 10 | replace: 'harbor.example.com/dockerhub-proxy' 11 | checkUpstream: false -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/indeedeng-alpha/harbor-container-webhook 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.1 6 | 7 | require ( 8 | github.com/containerd/containerd v1.7.27 9 | github.com/containers/image/v5 v5.34.2 10 | github.com/google/go-containerregistry v0.20.3 11 | github.com/opencontainers/image-spec v1.1.1 12 | github.com/prometheus/client_golang v1.21.1 13 | github.com/stretchr/testify v1.10.0 14 | gopkg.in/yaml.v2 v2.4.0 15 | k8s.io/api v0.32.3 16 | k8s.io/apimachinery v0.32.3 17 | k8s.io/client-go v0.32.3 18 | sigs.k8s.io/controller-runtime v0.20.3 19 | ) 20 | 21 | require ( 22 | github.com/beorn7/perks v1.0.1 // indirect 23 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 24 | github.com/containerd/errdefs v1.0.0 // indirect 25 | github.com/containerd/log v0.1.0 // indirect 26 | github.com/containerd/platforms v0.2.1 // indirect 27 | github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect 28 | github.com/containers/storage v1.57.2 // indirect 29 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 30 | github.com/docker/cli v28.0.1+incompatible // indirect 31 | github.com/docker/distribution v2.8.3+incompatible // indirect 32 | github.com/docker/docker-credential-helpers v0.9.3 // indirect 33 | github.com/emicklei/go-restful/v3 v3.12.2 // indirect 34 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 35 | github.com/fsnotify/fsnotify v1.8.0 // indirect 36 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 37 | github.com/go-logr/logr v1.4.2 // indirect 38 | github.com/go-logr/zapr v1.3.0 // indirect 39 | github.com/go-openapi/jsonpointer v0.21.1 // indirect 40 | github.com/go-openapi/jsonreference v0.21.0 // indirect 41 | github.com/go-openapi/swag v0.23.1 // indirect 42 | github.com/gogo/protobuf v1.3.2 // indirect 43 | github.com/golang/protobuf v1.5.4 // indirect 44 | github.com/google/btree v1.1.3 // indirect 45 | github.com/google/gnostic-models v0.6.9 // indirect 46 | github.com/google/go-cmp v0.7.0 // indirect 47 | github.com/google/gofuzz v1.2.0 // indirect 48 | github.com/google/uuid v1.6.0 // indirect 49 | github.com/josharian/intern v1.0.0 // indirect 50 | github.com/json-iterator/go v1.1.12 // indirect 51 | github.com/klauspost/compress v1.18.0 // indirect 52 | github.com/mailru/easyjson v0.9.0 // indirect 53 | github.com/mitchellh/go-homedir v1.1.0 // indirect 54 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 55 | github.com/modern-go/reflect2 v1.0.2 // indirect 56 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 57 | github.com/opencontainers/go-digest v1.0.0 // indirect 58 | github.com/pkg/errors v0.9.1 // indirect 59 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 60 | github.com/prometheus/client_model v0.6.1 // indirect 61 | github.com/prometheus/common v0.63.0 // indirect 62 | github.com/prometheus/procfs v0.15.1 // indirect 63 | github.com/sirupsen/logrus v1.9.3 // indirect 64 | github.com/spf13/pflag v1.0.6 // indirect 65 | github.com/vbatts/tar-split v0.12.1 // indirect 66 | github.com/x448/float16 v0.8.4 // indirect 67 | go.uber.org/multierr v1.11.0 // indirect 68 | go.uber.org/zap v1.27.0 // indirect 69 | golang.org/x/net v0.37.0 // indirect 70 | golang.org/x/oauth2 v0.28.0 // indirect 71 | golang.org/x/sync v0.12.0 // indirect 72 | golang.org/x/sys v0.31.0 // indirect 73 | golang.org/x/term v0.30.0 // indirect 74 | golang.org/x/text v0.23.0 // indirect 75 | golang.org/x/time v0.11.0 // indirect 76 | golang.org/x/tools v0.31.0 // indirect 77 | gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect 78 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect 79 | google.golang.org/grpc v1.71.0 // indirect 80 | google.golang.org/protobuf v1.36.5 // indirect 81 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 82 | gopkg.in/inf.v0 v0.9.1 // indirect 83 | gopkg.in/yaml.v3 v3.0.1 // indirect 84 | k8s.io/apiextensions-apiserver v0.32.3 // indirect 85 | k8s.io/klog/v2 v2.130.1 // indirect 86 | k8s.io/kube-openapi v0.0.0-20250304201544-e5f78fe3ede9 // indirect 87 | k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect 88 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 89 | sigs.k8s.io/randfill v1.0.0 // indirect 90 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 91 | sigs.k8s.io/yaml v1.4.0 // indirect 92 | ) 93 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/containerd/containerd v1.7.27 h1:yFyEyojddO3MIGVER2xJLWoCIn+Up4GaHFquP7hsFII= 6 | github.com/containerd/containerd v1.7.27/go.mod h1:xZmPnl75Vc+BLGt4MIfu6bp+fy03gdHAn9bz+FreFR0= 7 | github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 8 | github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 9 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 10 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 11 | github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= 12 | github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= 13 | github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= 14 | github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= 15 | github.com/containers/image/v5 v5.34.2 h1:3r1etun4uJYq5197tcymUcI1h6+zyzKS9PtRtBlEKMI= 16 | github.com/containers/image/v5 v5.34.2/go.mod h1:MG++slvQSZVq5ejAcLdu4APGsKGMb0YHHnAo7X28fdE= 17 | github.com/containers/storage v1.57.2 h1:2roCtTyE9pzIaBDHibK72DTnYkPmwWaq5uXxZdaWK4U= 18 | github.com/containers/storage v1.57.2/go.mod h1:i/Hb4lu7YgFr9G0K6BMjqW0BLJO1sFsnWQwj2UoWCUM= 19 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 22 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/docker/cli v28.0.1+incompatible h1:g0h5NQNda3/CxIsaZfH4Tyf6vpxFth7PYl3hgCPOKzs= 24 | github.com/docker/cli v28.0.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= 25 | github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= 26 | github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 27 | github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= 28 | github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= 29 | github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= 30 | github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 31 | github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= 32 | github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= 33 | github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= 34 | github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= 35 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 36 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 37 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 38 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 39 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 40 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 41 | github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= 42 | github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= 43 | github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= 44 | github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= 45 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 46 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 47 | github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= 48 | github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= 49 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 50 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 51 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 52 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 53 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 54 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 55 | github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= 56 | github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 57 | github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= 58 | github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= 59 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 60 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 61 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 62 | github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= 63 | github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= 64 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 65 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 66 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 67 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= 68 | github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 69 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 70 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 71 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 72 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 73 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 74 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 75 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 76 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 77 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 78 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 79 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 80 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 81 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 82 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 83 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 84 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 85 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 86 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 87 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 88 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 89 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 90 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 91 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 92 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 93 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 94 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 95 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 96 | github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= 97 | github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= 98 | github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= 99 | github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= 100 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 101 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 102 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 103 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 104 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 105 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 106 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 107 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 108 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 109 | github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= 110 | github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= 111 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 112 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 113 | github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 114 | github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 115 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 116 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 117 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 118 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 119 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 120 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 121 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 122 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 123 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 124 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 125 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 126 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 127 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 128 | github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= 129 | github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= 130 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 131 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 132 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 133 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 134 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 135 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 136 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 137 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 138 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 139 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 140 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 141 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 142 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 143 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 144 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 145 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 146 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 147 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 148 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 149 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 150 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 151 | golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= 152 | golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 153 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 154 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 155 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 156 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 157 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 158 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 159 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 160 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 161 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 162 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 163 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 164 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 165 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 166 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 167 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 168 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 169 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 170 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 171 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 172 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 173 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 174 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 175 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 176 | golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= 177 | golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= 178 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 179 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 180 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 181 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 182 | gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= 183 | gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= 184 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= 185 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= 186 | google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= 187 | google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= 188 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 189 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 190 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 191 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 192 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 193 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 194 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 195 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 196 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 197 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 198 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 199 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 200 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 201 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 202 | gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= 203 | gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= 204 | gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 205 | k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= 206 | k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= 207 | k8s.io/apiextensions-apiserver v0.32.3 h1:4D8vy+9GWerlErCwVIbcQjsWunF9SUGNu7O7hiQTyPY= 208 | k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss= 209 | k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= 210 | k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= 211 | k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= 212 | k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= 213 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 214 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 215 | k8s.io/kube-openapi v0.0.0-20250304201544-e5f78fe3ede9 h1:t0huyHnz6HsokckRxAF1bY0cqPFwzINKCL7yltEjZQc= 216 | k8s.io/kube-openapi v0.0.0-20250304201544-e5f78fe3ede9/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= 217 | k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= 218 | k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 219 | sigs.k8s.io/controller-runtime v0.20.3 h1:I6Ln8JfQjHH7JbtCD2HCYHoIzajoRxPNuvhvcDbZgkI= 220 | sigs.k8s.io/controller-runtime v0.20.3/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= 221 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= 222 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 223 | sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 224 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 225 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 226 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= 227 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= 228 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 229 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 230 | -------------------------------------------------------------------------------- /hack/.gitignore: -------------------------------------------------------------------------------- 1 | certs 2 | docker.json -------------------------------------------------------------------------------- /hack/config.yaml: -------------------------------------------------------------------------------- 1 | port: 9443 2 | certDir: "./hack/certs" 3 | healthAddr: ":8080" 4 | metricsAddr: ":8081" 5 | rules: 6 | - name: 'docker.io rewrite rule' 7 | # image refs must match at least one of the rules, and not match any excludes 8 | matches: 9 | - '^docker.io' 10 | excludes: 11 | # for example, exclude ubuntu from harbor's proxy cache 12 | - '^docker.io/(library/)?ubuntu:.*$' 13 | replace: 'harbor.example.com/dockerhub-proxy' 14 | checkUpstream: false 15 | - name: 'docker.io ubuntu rewrite rule' 16 | # image refs must match at least one of the rules, and not match any excludes 17 | matches: 18 | - '^docker.io/(library/)?ubuntu:.*$' 19 | replace: 'harbor-v2.awscmhqa2.k8s.indeed.tech/dockerhub-proxy-auth' 20 | checkUpstream: true # tests if the manifest for the rewritten image exists 21 | authSecretName: "harborv2-qa" -------------------------------------------------------------------------------- /hack/gencerts.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/bin/bash 3 | 4 | # Copyright 2018 The Kubernetes Authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | 18 | # Generates the a CA cert, a server key, and a server cert signed by the CA. 19 | # reference: 20 | # https://github.com/kubernetes/kubernetes/blob/master/plugin/pkg/admission/webhook/gencerts.sh 21 | set -o errexit 22 | set -o nounset 23 | set -o pipefail 24 | 25 | CN_BASE="harbor-container-webhook" 26 | TMP_DIR="./hack/certs" 27 | 28 | echo "Generating certs for the Harbor container webhook in ${TMP_DIR}." 29 | mkdir -p ${TMP_DIR} 30 | cat > ${TMP_DIR}/server.conf << EOF 31 | [req] 32 | req_extensions = v3_req 33 | distinguished_name = req_distinguished_name 34 | [req_distinguished_name] 35 | [ v3_req ] 36 | basicConstraints = CA:FALSE 37 | keyUsage = nonRepudiation, digitalSignature, keyEncipherment 38 | extendedKeyUsage = clientAuth, serverAuth 39 | subjectAltName = @alt_names 40 | 41 | [alt_names] 42 | DNS.1 = hcw-webhook.kube-system.svc 43 | DNS.2 = localhost 44 | EOF 45 | 46 | # Create a certificate authority 47 | openssl genrsa -out ${TMP_DIR}/caKey.pem 2048 48 | set +o errexit 49 | openssl req -x509 -new -nodes -key ${TMP_DIR}/caKey.pem -days 100000 -out ${TMP_DIR}/caCert.pem -subj "/CN=${CN_BASE}_ca" -addext "subjectAltName = DNS:${CN_BASE}_ca" 50 | if [[ $? -ne 0 ]]; then 51 | echo "ERROR: Failed to create CA certificate for self-signing. If the error is \"unknown option -addext\", update your openssl version" 52 | exit 1 53 | fi 54 | set -o errexit 55 | 56 | # Create a server certificate 57 | openssl genrsa -out ${TMP_DIR}/serverKey.pem 2048 58 | # Note the CN is the DNS name of the service of the webhook. 59 | openssl req -new -key ${TMP_DIR}/serverKey.pem -out ${TMP_DIR}/server.csr -subj "/CN=hcw-webhook.kube-system.svc" -config ${TMP_DIR}/server.conf -addext "subjectAltName = DNS:hcw-webhook.kube-system.svc" 60 | openssl x509 -req -in ${TMP_DIR}/server.csr -CA ${TMP_DIR}/caCert.pem -CAkey ${TMP_DIR}/caKey.pem -CAcreateserial -out ${TMP_DIR}/serverCert.pem -days 100000 -extensions SAN -extensions v3_req -extfile ${TMP_DIR}/server.conf 61 | mv ${TMP_DIR}/serverCert.pem ${TMP_DIR}/tls.crt 62 | mv ${TMP_DIR}/serverKey.pem ${TMP_DIR}/tls.key -------------------------------------------------------------------------------- /hack/test/admission.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "admission.k8s.io/v1beta1", 3 | "kind": "AdmissionReview", 4 | "request": { 5 | "uid": "705ab4f5-6393-11e8-b7cc-42010a800002", 6 | "kind": {"group":"","version":"v1","kind":"Pod"}, 7 | "resource": {"group":"","version":"v1","resource":"pods"}, 8 | "subResource": "", 9 | "name": "", 10 | "namespace": "", 11 | "operation": "CREATE", 12 | "object": { 13 | "metadata": { 14 | }, 15 | "spec": { 16 | "containers": [ 17 | { 18 | "name": "foo", 19 | "image": "ubuntu:latest" 20 | } 21 | ] 22 | } 23 | }, 24 | "userInfo": { 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /hack/test/no-op.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiVersion": "admission.k8s.io/v1beta1", 3 | "kind": "AdmissionReview", 4 | "request": { 5 | "uid": "705ab4f5-6393-11e8-b7cc-42010a800002", 6 | "kind": {"group":"","version":"v1","kind":"Pod"}, 7 | "resource": {"group":"","version":"v1","resource":"pods"}, 8 | "subResource": "", 9 | "name": "", 10 | "namespace": "", 11 | "operation": "CREATE", 12 | "object": { 13 | "metadata": { 14 | }, 15 | "spec": { 16 | "containers": [ 17 | { 18 | "name": "foo", 19 | "image": "private.example.com/busybox:latest" 20 | } 21 | ] 22 | } 23 | }, 24 | "userInfo": { 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "gopkg.in/yaml.v2" 8 | ) 9 | 10 | func LoadConfiguration(path string) (*Configuration, error) { 11 | conf := &Configuration{} 12 | data, err := os.ReadFile(path) 13 | if err != nil { 14 | return nil, err 15 | } 16 | if err := yaml.Unmarshal(data, conf); err != nil { 17 | return nil, err 18 | } 19 | 20 | ns := detectNamespace() 21 | for i := range conf.Rules { 22 | conf.Rules[i].Namespace = ns 23 | if len(conf.Rules[i].Platforms) == 0 { 24 | conf.Rules[i].Platforms = []string{"linux/amd64"} 25 | } 26 | } 27 | return conf, nil 28 | } 29 | 30 | func detectNamespace() string { 31 | // This way assumes you've set the POD_NAMESPACE environment variable using the downward API. 32 | // This check has to be done first for backwards compatibility with the way InClusterConfig was originally set up 33 | if ns := os.Getenv("POD_NAMESPACE"); ns != "" { 34 | return ns 35 | } 36 | 37 | // Fall back to the namespace associated with the service account token, if available 38 | if data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil { 39 | if ns := strings.TrimSpace(string(data)); ns != "" { 40 | return ns 41 | } 42 | } 43 | 44 | return "default" 45 | } 46 | 47 | // Configuration loads and keeps the related configuration items for the webhook. 48 | type Configuration struct { 49 | // Port that the webhook listens on for admission review submissions 50 | Port int `yaml:"port"` 51 | // CertDir the directory that contains the webhook server key and certificate. 52 | // If not set, webhook server would look up the server key and certificate in 53 | // {TempDir}/k8s-webhook-server/serving-certs. The server key and certificate 54 | // must be named tls.key and tls.crt, respectively. 55 | CertDir string `yaml:"certDir"` 56 | // MetricsAddr is the address the metric endpoint binds to. 57 | MetricsAddr string `yaml:"metricsAddr"` 58 | // HealthAddr is the address the readiness and health probes are mounted to. 59 | HealthAddr string `yaml:"healthAddr"` 60 | // Rules is the list of directives to use to evaluate pod container images. 61 | Rules []ProxyRule `yaml:"rules"` 62 | // Verbose enables trace logging. 63 | Verbose bool `yaml:"verbose"` 64 | } 65 | 66 | // ProxyRule contains a list of regex rules used to match against images. Image references that match and are not 67 | // excluded have their registry rewritten with the replacement string. 68 | type ProxyRule struct { 69 | // Name of the ProxyRule. 70 | Name string `yaml:"name"` 71 | // Matches is a list of regular expressions that match a registry in an image, e.g '^docker.io'. 72 | Matches []string `yaml:"matches"` 73 | // Excludes is a list of regular expressions whose images that match should be excluded from this rule. 74 | Excludes []string `yaml:"excludes"` 75 | // Replace is the string used to rewrite the registry in matching rules. 76 | Replace string `yaml:"replace"` 77 | 78 | // CheckUpstream enables an additional check to ensure the image manifest exists before rewriting. 79 | // If the webhook lacks permissions to fetch the image manifest or the registry is down, the image 80 | // will not be rewritten. Experimental. 81 | CheckUpstream bool `yaml:"checkUpstream"` 82 | // List of the required platforms to check for if CheckUpstream is set. Defaults to "linux/amd64" if unset. 83 | Platforms []string `yaml:"platforms"` 84 | // AuthSecretName is a reference to an image pull secret (must be .dockerconfigjson type) which 85 | // will be used to authenticate if `checkUpstream` is set. Unused if not specified or `checkUpstream` is false. 86 | AuthSecretName string `yaml:"authSecretName"` 87 | // Namespace that the webhook is running in, used for accessing secrets for authenticated proxy rules 88 | Namespace string 89 | } 90 | -------------------------------------------------------------------------------- /internal/webhook/docker.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/containers/image/v5/docker/reference" 10 | ) 11 | 12 | const BareRegistry = "docker.io" 13 | 14 | // RegistryFromImageRef returns the registry (and port, if set) from the image reference, 15 | // otherwise returns the default bare registry, "docker.io". 16 | func RegistryFromImageRef(imageReference string) (registry string, err error) { 17 | ref, err := reference.ParseDockerRef(imageReference) 18 | if err != nil { 19 | return "", err 20 | } 21 | return reference.Domain(ref), nil 22 | } 23 | 24 | // ReplaceRegistryInImageRef returns the image reference with the registry replaced. 25 | func ReplaceRegistryInImageRef(imageReference, replacementRegistry string) (imageRef string, err error) { 26 | named, err := reference.ParseDockerRef(imageReference) 27 | if err != nil { 28 | return "", err 29 | } 30 | return strings.Replace(named.String(), reference.Domain(named), replacementRegistry, 1), nil 31 | } 32 | 33 | // below is adapted from kubelet internals, see: https://github.com/kubernetes/kubernetes/blob/master/pkg/credentialprovider/config.go 34 | /* 35 | Copyright 2014 The Kubernetes Authors. 36 | 37 | Licensed under the Apache License, Version 2.0 (the "License"); 38 | you may not use this file except in compliance with the License. 39 | You may obtain a copy of the License at 40 | 41 | http://www.apache.org/licenses/LICENSE-2.0 42 | 43 | Unless required by applicable law or agreed to in writing, software 44 | distributed under the License is distributed on an "AS IS" BASIS, 45 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 46 | See the License for the specific language governing permissions and 47 | limitations under the License. 48 | */ 49 | 50 | // DockerConfigJSON represents ~/.docker/config.json file info 51 | // see https://github.com/docker/docker/pull/12009 52 | type DockerConfigJSON struct { 53 | Auths DockerConfig `json:"auths"` 54 | // +optional 55 | HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"` 56 | } 57 | 58 | // DockerConfig represents the config file used by the docker CLI. 59 | // This config that represents the credentials that should be used 60 | // when pulling images from specific image repositories. 61 | type DockerConfig map[string]dockerConfigEntryWithAuth 62 | 63 | type DockerConfigEntry struct { 64 | Username string 65 | Password string 66 | Email string 67 | } 68 | 69 | // dockerConfigEntryWithAuth is used solely for deserializing the Auth field 70 | // into a dockerConfigEntry during JSON deserialization. 71 | type dockerConfigEntryWithAuth struct { 72 | // +optional 73 | Username string `json:"username,omitempty"` 74 | // +optional 75 | Password string `json:"password,omitempty"` 76 | // +optional 77 | Email string `json:"email,omitempty"` 78 | // +optional 79 | Auth string `json:"auth,omitempty"` 80 | } 81 | 82 | // UnmarshalJSON implements the json.Unmarshaler interface. 83 | func (ident *DockerConfigEntry) UnmarshalJSON(data []byte) error { 84 | var tmp dockerConfigEntryWithAuth 85 | err := json.Unmarshal(data, &tmp) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | ident.Username = tmp.Username 91 | ident.Password = tmp.Password 92 | ident.Email = tmp.Email 93 | 94 | if tmp.Auth == "" { 95 | return nil 96 | } 97 | 98 | ident.Username, ident.Password, err = decodeDockerConfigFieldAuth(tmp.Auth) 99 | return err 100 | } 101 | 102 | // MarshalJSON implements the json.Marshaler interface. 103 | func (ident DockerConfigEntry) MarshalJSON() ([]byte, error) { 104 | toEncode := dockerConfigEntryWithAuth{ident.Username, ident.Password, ident.Email, ""} 105 | toEncode.Auth = encodeDockerConfigFieldAuth(ident.Username, ident.Password) 106 | 107 | return json.Marshal(toEncode) 108 | } 109 | 110 | // decodeDockerConfigFieldAuth deserializes the "auth" field from dockercfg into a 111 | // username and a password. The format of the auth field is base64(:). 112 | func decodeDockerConfigFieldAuth(field string) (username, password string, err error) { 113 | 114 | var decoded []byte 115 | 116 | // StdEncoding can only decode padded string 117 | // RawStdEncoding can only decode unpadded string 118 | if strings.HasSuffix(strings.TrimSpace(field), "=") { 119 | // decode padded data 120 | decoded, err = base64.StdEncoding.DecodeString(field) 121 | } else { 122 | // decode unpadded data 123 | decoded, err = base64.RawStdEncoding.DecodeString(field) 124 | } 125 | 126 | if err != nil { 127 | return 128 | } 129 | 130 | parts := strings.SplitN(string(decoded), ":", 2) 131 | if len(parts) != 2 { 132 | err = fmt.Errorf("unable to parse auth field, must be formatted as base64(username:password)") 133 | return 134 | } 135 | 136 | username = parts[0] 137 | password = parts[1] 138 | 139 | return 140 | } 141 | 142 | func encodeDockerConfigFieldAuth(username, password string) string { 143 | fieldValue := username + ":" + password 144 | 145 | return base64.StdEncoding.EncodeToString([]byte(fieldValue)) 146 | } 147 | -------------------------------------------------------------------------------- /internal/webhook/docker_test.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/containers/image/v5/docker/reference" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func Test_RegistryFromImageRef(t *testing.T) { 12 | type testcase struct { 13 | description string 14 | imageRef string 15 | expectedRegistry string 16 | } 17 | tests := []testcase{ 18 | { 19 | description: "image reference with hostname with port and image tag set", 20 | imageRef: "somehost:443/public/busybox:latest", 21 | expectedRegistry: "somehost:443", 22 | }, 23 | { 24 | description: "image reference with hostname with port and no image tag set", 25 | imageRef: "somehost:443/public/busybox", 26 | expectedRegistry: "somehost:443", 27 | }, 28 | { 29 | description: "image reference with hostname with port and image sha set", 30 | imageRef: "somehost:443/public/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa", 31 | expectedRegistry: "somehost:443", 32 | }, 33 | { 34 | description: "image reference with url and image tag set", 35 | imageRef: "example.com/busybox:latest", 36 | expectedRegistry: "example.com", 37 | }, 38 | { 39 | description: "image reference with url and no image tag set", 40 | imageRef: "example.com/busybox", 41 | expectedRegistry: "example.com", 42 | }, 43 | { 44 | description: "image reference with url, project and no image tag set", 45 | imageRef: "example.com/nginxinc/nginx-unprivileged", 46 | expectedRegistry: "example.com", 47 | }, 48 | { 49 | description: "image reference with url and image sha set", 50 | imageRef: "example.com/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa", 51 | expectedRegistry: "example.com", 52 | }, 53 | { 54 | description: "bare image reference with image tag set", 55 | imageRef: "busybox:latest", 56 | expectedRegistry: BareRegistry, 57 | }, 58 | { 59 | description: "bare image reference with project and no image tag set", 60 | imageRef: "nginxinc/nginx-unprivileged", 61 | expectedRegistry: BareRegistry, 62 | }, 63 | { 64 | description: "bare image reference with and no image tag set", 65 | imageRef: "busybox", 66 | expectedRegistry: BareRegistry, 67 | }, 68 | { 69 | description: "bare image reference with image sha set", 70 | imageRef: "busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa", 71 | expectedRegistry: BareRegistry, 72 | }, 73 | } 74 | for _, testcase := range tests { 75 | output, err := RegistryFromImageRef(testcase.imageRef) 76 | require.NoError(t, err, testcase.description) 77 | require.Equal(t, testcase.expectedRegistry, output, testcase.description) 78 | } 79 | } 80 | 81 | func Test_RegistryFromImageRef_EmptyErr(t *testing.T) { 82 | _, err := RegistryFromImageRef("") 83 | require.EqualError(t, err, reference.ErrReferenceInvalidFormat.Error()) 84 | } 85 | 86 | func Test_ReplaceRegistryInImageRef(t *testing.T) { 87 | type testcase struct { 88 | description string 89 | imageRef string 90 | newRegistry string 91 | expectedRef string 92 | } 93 | tests := []testcase{ 94 | { 95 | description: "image reference with hostname with port and image tag set", 96 | imageRef: "somehost:443/public/busybox:1.32.0", 97 | newRegistry: "harbor.example.com/proxy-cache", 98 | expectedRef: "harbor.example.com/proxy-cache/public/busybox:1.32.0", 99 | }, 100 | { 101 | description: "image reference with hostname with port and no image tag set", 102 | imageRef: "somehost:443/public/busybox", 103 | newRegistry: "harbor.example.com/proxy-cache", 104 | expectedRef: "harbor.example.com/proxy-cache/public/busybox:latest", 105 | }, 106 | { 107 | description: "image reference with hostname with port and image sha set", 108 | imageRef: "somehost:443/public/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa", 109 | newRegistry: "harbor.example.com/proxy-cache", 110 | expectedRef: "harbor.example.com/proxy-cache/public/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa", 111 | }, 112 | { 113 | description: "image reference with url and image tag set", 114 | imageRef: "example.com/busybox:1.32.0", 115 | newRegistry: "harbor.example.com/proxy-cache", 116 | expectedRef: "harbor.example.com/proxy-cache/busybox:1.32.0", 117 | }, 118 | { 119 | description: "image reference with url and no image tag set", 120 | imageRef: "example.com/busybox", 121 | newRegistry: "harbor.example.com/proxy-cache", 122 | expectedRef: "harbor.example.com/proxy-cache/busybox:latest", 123 | }, 124 | { 125 | description: "image reference with url and image sha set", 126 | imageRef: "example.com/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa", 127 | newRegistry: "harbor.example.com/proxy-cache", 128 | expectedRef: "harbor.example.com/proxy-cache/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa", 129 | }, 130 | { 131 | description: "bare image reference with image tag set", 132 | imageRef: "busybox:1.32.0", 133 | newRegistry: "harbor.example.com/proxy-cache", 134 | expectedRef: "harbor.example.com/proxy-cache/library/busybox:1.32.0", 135 | }, 136 | { 137 | description: "bare image reference with and no image tag set", 138 | imageRef: "busybox", 139 | newRegistry: "harbor.example.com/proxy-cache", 140 | expectedRef: "harbor.example.com/proxy-cache/library/busybox:latest", 141 | }, 142 | { 143 | description: "bare image reference with image sha set", 144 | imageRef: "busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa", 145 | newRegistry: "harbor.example.com/proxy-cache", 146 | expectedRef: "harbor.example.com/proxy-cache/library/busybox@sha256:7cc4b5aefd1d0cadf8d97d4350462ba51c694ebca145b08d7d41b41acc8db5aa", 147 | }, 148 | } 149 | for _, testcase := range tests { 150 | output, err := ReplaceRegistryInImageRef(testcase.imageRef, testcase.newRegistry) 151 | require.NoError(t, err) 152 | require.Equal(t, testcase.expectedRef, output, testcase.description) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /internal/webhook/manifest.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | // slimManifest is a partial representation of the oci manifest to access the mediaType. 4 | type slimManifest struct { 5 | MediaType string `json:"mediaType"` 6 | } 7 | 8 | type platform struct { 9 | Architecture string `json:"architecture"` 10 | OS string `json:"os"` 11 | } 12 | 13 | // indexManifest is a partial representation of the sub manifest present in a manifest list. 14 | type indexManifest struct { 15 | MediaType string `json:"mediaType"` 16 | Platform platform `json:"platform"` 17 | } 18 | 19 | // slimManifestList is a partial representation of the oci manifest list to access the supported architectures. 20 | type slimManifestList struct { 21 | MediaType string `json:"mediaType"` 22 | Manifests []indexManifest `json:"manifests"` 23 | } 24 | -------------------------------------------------------------------------------- /internal/webhook/mutate.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | corev1 "k8s.io/api/core/v1" 10 | 11 | ctrl "sigs.k8s.io/controller-runtime" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 14 | ) 15 | 16 | var ( 17 | logger = ctrl.Log.WithName("mutator") 18 | ) 19 | 20 | // PodContainerProxier mutates init containers and containers to redirect them to the harbor proxy cache if one exists. 21 | type PodContainerProxier struct { 22 | Client client.Client 23 | Decoder admission.Decoder 24 | Transformers []ContainerTransformer 25 | Verbose bool 26 | 27 | // kube config settings 28 | KubeClientBurst int 29 | KubeClientQPS float32 30 | } 31 | 32 | // Handle mutates init containers and containers. 33 | func (p *PodContainerProxier) Handle(ctx context.Context, req admission.Request) admission.Response { 34 | pod := &corev1.Pod{} 35 | 36 | err := p.Decoder.Decode(req, pod) 37 | if err != nil { 38 | return admission.Errored(http.StatusBadRequest, err) 39 | } 40 | 41 | initContainers, updatedInit, err := p.updateContainers(ctx, pod.Spec.InitContainers, "init") 42 | if err != nil { 43 | return admission.Errored(http.StatusInternalServerError, err) 44 | } 45 | containers, updated, err := p.updateContainers(ctx, pod.Spec.Containers, "normal") 46 | if err != nil { 47 | return admission.Errored(http.StatusInternalServerError, err) 48 | } 49 | if !updated && !updatedInit { 50 | return admission.Allowed("no updates") 51 | } 52 | pod.Spec.InitContainers = initContainers 53 | pod.Spec.Containers = containers 54 | 55 | marshaledPod, err := json.Marshal(pod) 56 | if err != nil { 57 | return admission.Errored(http.StatusInternalServerError, err) 58 | } 59 | return admission.PatchResponseFromRaw(req.Object.Raw, marshaledPod) 60 | } 61 | 62 | func (p *PodContainerProxier) lookupNodeArchAndOS(ctx context.Context, restClient client.Client, nodeName string) (platform, os string, err error) { 63 | node := corev1.Node{} 64 | if err = restClient.Get(ctx, client.ObjectKey{Name: nodeName}, &node); err != nil { 65 | return "", "", fmt.Errorf("failed to lookup node %s: %w", nodeName, err) 66 | } 67 | logger.Info(fmt.Sprintf("node %v", node)) 68 | return node.Status.NodeInfo.Architecture, node.Status.NodeInfo.OperatingSystem, nil 69 | } 70 | 71 | func (p *PodContainerProxier) updateContainers(ctx context.Context, containers []corev1.Container, kind string) ([]corev1.Container, bool, error) { 72 | containersReplacement := make([]corev1.Container, 0, len(containers)) 73 | updated := false 74 | for i := range containers { 75 | container := containers[i] 76 | imageRef, err := p.rewriteImage(ctx, container.Image) 77 | if err != nil { 78 | return []corev1.Container{}, false, err 79 | } 80 | if !updated { 81 | updated = imageRef != container.Image 82 | } 83 | if imageRef != container.Image { 84 | logger.Info(fmt.Sprintf("rewriting the image of %q from %q to %q", container.Name, container.Image, imageRef)) 85 | } 86 | container.Image = imageRef 87 | containersReplacement = append(containersReplacement, container) 88 | } 89 | return containersReplacement, updated, nil 90 | } 91 | 92 | func (p *PodContainerProxier) rewriteImage(ctx context.Context, imageRef string) (string, error) { 93 | for _, transformer := range p.Transformers { 94 | updatedRef, err := transformer.RewriteImage(imageRef) 95 | if err != nil { 96 | return "", fmt.Errorf("transformer %q failed to update imageRef %q: %w", transformer.Name(), imageRef, err) 97 | } 98 | if updatedRef != imageRef { 99 | if found, err := transformer.CheckUpstream(ctx, updatedRef); err != nil { 100 | logger.Info(fmt.Sprintf("transformer %q skipping rewriting %q to %q, could not fetch image manifest: %s", transformer.Name(), imageRef, updatedRef, err.Error())) 101 | continue 102 | } else if !found { 103 | logger.Info(fmt.Sprintf("transformer %q skipping rewriting %q to %q, registry reported image not found.", transformer.Name(), imageRef, updatedRef)) 104 | continue 105 | } 106 | logger.Info(fmt.Sprintf("transformer %q rewriting %q to %q", transformer.Name(), imageRef, updatedRef)) 107 | return updatedRef, nil 108 | } 109 | } 110 | return imageRef, nil 111 | } 112 | 113 | // PodContainerProxier implements admission.DecoderInjector. 114 | // A decoder will be automatically injected. 115 | 116 | // InjectDecoder injects the decoder. 117 | func (p *PodContainerProxier) InjectDecoder(d admission.Decoder) error { 118 | p.Decoder = d 119 | return nil 120 | } 121 | -------------------------------------------------------------------------------- /internal/webhook/mutate_test.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/indeedeng-alpha/harbor-container-webhook/internal/config" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestPodContainerProxier_rewriteImage(t *testing.T) { 13 | transformers, err := MakeTransformers([]config.ProxyRule{ 14 | { 15 | Name: "docker.io proxy cache except ubuntu", 16 | Matches: []string{"^docker.io"}, 17 | Excludes: []string{"^docker.io/(library/)?ubuntu:.*$"}, 18 | Replace: "harbor.example.com/dockerhub-proxy", 19 | }, 20 | { 21 | Name: "quay.io proxy cache", 22 | Matches: []string{"^quay.io"}, 23 | Replace: "harbor.example.com/quay-proxy", 24 | }, 25 | { 26 | Name: "docker.io proxy cache but only ubuntu", 27 | Matches: []string{"^docker.io/(library/)?ubuntu"}, 28 | Replace: "harbor.example.com/ubuntu-proxy", 29 | }, 30 | }, nil) 31 | require.NoError(t, err) 32 | proxier := PodContainerProxier{ 33 | Transformers: transformers, 34 | } 35 | 36 | type testcase struct { 37 | name string 38 | image string 39 | platform string 40 | os string 41 | expected string 42 | } 43 | tests := []testcase{ 44 | { 45 | name: "an image from quay should be rewritten", 46 | image: "quay.io/bitnami/sealed-secrets-controller:latest", 47 | os: "linux", 48 | platform: "amd64", 49 | expected: "harbor.example.com/quay-proxy/bitnami/sealed-secrets-controller:latest", 50 | }, 51 | { 52 | name: "an image from quay without a tag should be rewritten", 53 | image: "quay.io/bitnami/sealed-secrets-controller", 54 | os: "linux", 55 | platform: "amd64", 56 | expected: "harbor.example.com/quay-proxy/bitnami/sealed-secrets-controller:latest", 57 | }, 58 | { 59 | name: "an image from docker.io with ubuntu should be rewritten to the ubuntu proxy", 60 | image: "docker.io/library/ubuntu:latest", 61 | os: "linux", 62 | platform: "amd64", 63 | expected: "harbor.example.com/ubuntu-proxy/library/ubuntu:latest", 64 | }, 65 | { 66 | name: "a bare ubuntu image from docker.io should be rewritten to the ubuntu proxy", 67 | image: "ubuntu", 68 | os: "linux", 69 | platform: "amd64", 70 | expected: "harbor.example.com/ubuntu-proxy/library/ubuntu:latest", 71 | }, 72 | { 73 | name: "an image from docker.io should be rewritten", 74 | image: "docker.io/library/centos:latest", 75 | os: "linux", 76 | platform: "amd64", 77 | expected: "harbor.example.com/dockerhub-proxy/library/centos:latest", 78 | }, 79 | { 80 | name: "a bare image from docker.io should be rewritten", 81 | image: "centos", 82 | os: "linux", 83 | platform: "amd64", 84 | expected: "harbor.example.com/dockerhub-proxy/library/centos:latest", 85 | }, 86 | { 87 | name: "an image from gcr should not be rewritten", 88 | image: "k8s.gcr.io/kubernetes", 89 | os: "linux", 90 | platform: "amd64", 91 | expected: "k8s.gcr.io/kubernetes", 92 | }, 93 | } 94 | for _, tc := range tests { 95 | t.Run(tc.name, func(t *testing.T) { 96 | rewritten, err := proxier.rewriteImage(context.TODO(), tc.image) 97 | require.NoError(t, err) 98 | require.Equal(t, tc.expected, rewritten) 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /internal/webhook/transfomer_test.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/indeedeng-alpha/harbor-container-webhook/internal/config" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestRuleTransformer_RewriteImage(t *testing.T) { 12 | transformer, err := newRuleTransformer(config.ProxyRule{ 13 | Name: "test rules", 14 | Matches: []string{"^docker.io"}, 15 | Excludes: []string{"^docker.io/(library/)?ubuntu:.*$"}, 16 | Replace: "harbor.example.com/dockerhub-proxy", 17 | }) 18 | require.NoError(t, err) 19 | 20 | type testcase struct { 21 | name string 22 | image string 23 | platform string 24 | os string 25 | expected string 26 | } 27 | tests := []testcase{ 28 | { 29 | name: "an image from quay should not be rewritten", 30 | image: "quay.io/bitnami/sealed-secrets-controller:latest", 31 | os: "linux", 32 | platform: "amd64", 33 | expected: "quay.io/bitnami/sealed-secrets-controller:latest", 34 | }, 35 | { 36 | name: "an image from quay without a tag should not be rewritten", 37 | image: "quay.io/bitnami/sealed-secrets-controller", 38 | os: "linux", 39 | platform: "amd64", 40 | expected: "quay.io/bitnami/sealed-secrets-controller", 41 | }, 42 | { 43 | name: "an image from dockerhub explicitly excluded should not be rewritten", 44 | image: "docker.io/library/ubuntu:latest", 45 | os: "linux", 46 | platform: "amd64", 47 | expected: "docker.io/library/ubuntu:latest", 48 | }, 49 | { 50 | name: "a bare image from dockerhub explicitly excluded should not be rewritten", 51 | image: "ubuntu", 52 | os: "linux", 53 | platform: "amd64", 54 | expected: "ubuntu", 55 | }, 56 | { 57 | name: "an image from dockerhub should be rewritten", 58 | image: "docker.io/library/centos:latest", 59 | os: "linux", 60 | platform: "amd64", 61 | expected: "harbor.example.com/dockerhub-proxy/library/centos:latest", 62 | }, 63 | { 64 | name: "an image from the std library should be rewritten", 65 | image: "centos", 66 | os: "linux", 67 | platform: "amd64", 68 | expected: "harbor.example.com/dockerhub-proxy/library/centos:latest", 69 | }, 70 | } 71 | for _, tc := range tests { 72 | t.Run(tc.name, func(t *testing.T) { 73 | rewritten, err := transformer.RewriteImage(tc.image) 74 | require.NoError(t, err) 75 | require.Equal(t, tc.expected, rewritten) 76 | }) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /internal/webhook/transformer.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | "github.com/containerd/containerd/images" 12 | 13 | "github.com/google/go-containerregistry/pkg/authn" 14 | "github.com/google/go-containerregistry/pkg/crane" 15 | 16 | "github.com/indeedeng-alpha/harbor-container-webhook/internal/config" 17 | 18 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 19 | 20 | "github.com/prometheus/client_golang/prometheus" 21 | 22 | corev1 "k8s.io/api/core/v1" 23 | 24 | "sigs.k8s.io/controller-runtime/pkg/client" 25 | "sigs.k8s.io/controller-runtime/pkg/metrics" 26 | ) 27 | 28 | var ( 29 | rewrite = prometheus.NewCounterVec(prometheus.CounterOpts{ 30 | Namespace: "hcw", 31 | Subsystem: "rules", 32 | Name: "rewrite_success", 33 | Help: "image rewrite success metrics for this rule", 34 | }, []string{"name"}) 35 | rewriteTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 36 | Namespace: "hcw", 37 | Subsystem: "rules", 38 | Name: "rewrite_duration_seconds", 39 | Help: "image rewrite duration distribution for this rule", 40 | }, []string{"name"}) 41 | rewriteErrors = prometheus.NewCounterVec(prometheus.CounterOpts{ 42 | Namespace: "hcw", 43 | Subsystem: "rules", 44 | Name: "rewrite_errors", 45 | Help: "errors while parsing and rewriting images for this rule", 46 | }, []string{"name"}) 47 | upstream = prometheus.NewCounterVec(prometheus.CounterOpts{ 48 | Namespace: "hcw", 49 | Subsystem: "rules", 50 | Name: "upstream_checks", 51 | Help: "image rewrite upstream checks that succeeded this rule", 52 | }, []string{"name"}) 53 | upstreamErrors = prometheus.NewCounterVec(prometheus.CounterOpts{ 54 | Namespace: "hcw", 55 | Subsystem: "rules", 56 | Name: "upstream_check_errors", 57 | Help: "image rewrite upstream checks that errored for this rule", 58 | }, []string{"name"}) 59 | ) 60 | 61 | func init() { 62 | metrics.Registry.MustRegister(rewrite, rewriteTime, rewriteErrors, upstream, upstreamErrors) 63 | } 64 | 65 | var invalidMetricChars = regexp.MustCompile(`[^a-zA-Z0-9_]`) 66 | 67 | // ContainerTransformer rewrites docker image references for harbor proxy cache projects. 68 | type ContainerTransformer interface { 69 | // Name returns the name of the transformer rule 70 | Name() string 71 | 72 | // RewriteImage takes a docker image reference and returns the same image reference rewritten for a harbor 73 | // proxy cache project endpoint, if one is available, else returns the original image reference. 74 | RewriteImage(imageRef string) (string, error) 75 | 76 | // CheckUpstream ensures that the docker image reference exists in the upstream registry 77 | // and returns if the image exists, or an error if the registry can't be contacted. 78 | CheckUpstream(ctx context.Context, imageRef string) (bool, error) 79 | } 80 | 81 | func MakeTransformers(rules []config.ProxyRule, client client.Client) ([]ContainerTransformer, error) { 82 | transformers := make([]ContainerTransformer, 0, len(rules)) 83 | for _, rule := range rules { 84 | transformer, err := newRuleTransformer(rule) 85 | transformer.client = client 86 | if err != nil { 87 | return nil, err 88 | } 89 | transformers = append(transformers, transformer) 90 | } 91 | return transformers, nil 92 | } 93 | 94 | type ruleTransformer struct { 95 | rule config.ProxyRule 96 | metricName string 97 | 98 | client client.Client 99 | 100 | matches []*regexp.Regexp 101 | excludes []*regexp.Regexp 102 | } 103 | 104 | var _ ContainerTransformer = (*ruleTransformer)(nil) 105 | 106 | func newRuleTransformer(rule config.ProxyRule) (*ruleTransformer, error) { 107 | transformer := &ruleTransformer{ 108 | rule: rule, 109 | metricName: invalidMetricChars.ReplaceAllString(strings.ToLower(rule.Name), "_"), 110 | matches: make([]*regexp.Regexp, 0, len(rule.Matches)), 111 | excludes: make([]*regexp.Regexp, 0, len(rule.Excludes)), 112 | } 113 | for _, matchRegex := range rule.Matches { 114 | matcher, err := regexp.Compile(matchRegex) 115 | if err != nil { 116 | return nil, fmt.Errorf("failed to compile regex %q: %w", matchRegex, err) 117 | } 118 | transformer.matches = append(transformer.matches, matcher) 119 | } 120 | for _, excludeRegex := range rule.Excludes { 121 | excluder, err := regexp.Compile(excludeRegex) 122 | if err != nil { 123 | return nil, fmt.Errorf("failed to compile exclude regex %q: %w", excludeRegex, err) 124 | } 125 | transformer.excludes = append(transformer.excludes, excluder) 126 | } 127 | 128 | return transformer, nil 129 | } 130 | 131 | func (t *ruleTransformer) Name() string { 132 | return t.rule.Name 133 | } 134 | 135 | func (t *ruleTransformer) CheckUpstream(ctx context.Context, imageRef string) (bool, error) { 136 | if !t.rule.CheckUpstream { 137 | return true, nil 138 | } 139 | 140 | options := make([]crane.Option, 0) 141 | if t.rule.AuthSecretName != "" { 142 | auth, err := t.auth(ctx, imageRef) 143 | if err != nil { 144 | return false, err 145 | } 146 | options = append(options, crane.WithAuth(auth)) 147 | } 148 | // we don't pass in the platform to crane to retrieve the full manifest list for multi-arch 149 | options = append(options, crane.WithContext(ctx)) 150 | manifestBytes, err := crane.Manifest(imageRef, options...) 151 | if err != nil { 152 | upstreamErrors.WithLabelValues(t.metricName).Inc() 153 | return false, err 154 | } 155 | 156 | // try and parse the manifest to decode the MediaType to determine if it's a manifest or manifest list 157 | manifest := slimManifest{} 158 | if err := json.Unmarshal(manifestBytes, &manifest); err != nil { 159 | upstreamErrors.WithLabelValues(t.metricName).Inc() 160 | return false, fmt.Errorf("failed to parse manifest %s payload=%s: %w", imageRef, string(manifestBytes), err) 161 | } 162 | 163 | switch manifest.MediaType { 164 | case images.MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex: 165 | manifestList := slimManifestList{} 166 | if err := json.Unmarshal(manifestBytes, &manifestList); err != nil { 167 | upstreamErrors.WithLabelValues(t.metricName).Inc() 168 | return false, fmt.Errorf("failed to parse manifest list %s, payload=%s: %w", imageRef, string(manifestBytes), err) 169 | } 170 | matches := 0 171 | for _, rulePlatform := range t.rule.Platforms { 172 | for _, subManifest := range manifestList.Manifests { 173 | subPlatform := subManifest.Platform.OS + "/" + subManifest.Platform.Architecture 174 | if subPlatform == rulePlatform { 175 | matches++ 176 | break 177 | } 178 | } 179 | } 180 | if matches == len(t.rule.Platforms) { 181 | upstream.WithLabelValues(t.metricName).Inc() 182 | return true, nil 183 | } 184 | 185 | return false, nil 186 | case images.MediaTypeDockerSchema1Manifest, images.MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest: 187 | upstream.WithLabelValues(t.metricName).Inc() 188 | return true, nil 189 | default: 190 | logger.Info(fmt.Sprintf("unknown manifest media type: %s, rule=%s,imageRef=%s", manifest.MediaType, t.rule.Name, imageRef)) 191 | upstream.WithLabelValues(t.metricName).Inc() 192 | return true, nil 193 | } 194 | } 195 | 196 | func (t *ruleTransformer) auth(ctx context.Context, imageRef string) (authn.Authenticator, error) { 197 | var secret corev1.Secret 198 | logger.Info("token key: ", "key", client.ObjectKey{Namespace: t.rule.Namespace, Name: t.rule.AuthSecretName}) 199 | if err := t.client.Get(ctx, client.ObjectKey{Namespace: t.rule.Namespace, Name: t.rule.AuthSecretName}, &secret); err != nil { 200 | return nil, fmt.Errorf("failed to get secret %q for upstream manifests: %w", t.rule.AuthSecretName, err) 201 | } 202 | 203 | if dockerConfigJSONBytes, dockerConfigJSONExists := secret.Data[corev1.DockerConfigJsonKey]; (secret.Type == corev1.SecretTypeDockerConfigJson) && dockerConfigJSONExists && (len(dockerConfigJSONBytes) > 0) { 204 | dockerConfigJSON := DockerConfigJSON{} 205 | if err := json.Unmarshal(dockerConfigJSONBytes, &dockerConfigJSON); err != nil { 206 | return nil, err 207 | } 208 | for key, method := range dockerConfigJSON.Auths { 209 | keyRegex := regexp.MustCompile(key) 210 | if keyRegex.Find([]byte(imageRef)) != nil { 211 | if method.Auth != "" { 212 | user, pass, err := decodeDockerConfigFieldAuth(method.Auth) 213 | if err != nil { 214 | return nil, fmt.Errorf("failed to parse auth docker config auth field in secret %q", t.rule.AuthSecretName) 215 | } 216 | return &authn.Basic{Username: user, Password: pass}, nil 217 | } 218 | return &authn.Basic{Username: method.Username, Password: method.Password}, nil 219 | } 220 | } 221 | } 222 | 223 | return nil, fmt.Errorf("failed to parse auth secret %q, no docker config found", t.rule.AuthSecretName) 224 | } 225 | 226 | func (t *ruleTransformer) RewriteImage(imageRef string) (string, error) { 227 | start := time.Now() 228 | rewritten, updatedRef, err := t.doRewriteImage(imageRef) 229 | duration := time.Since(start) 230 | if err != nil { 231 | rewriteErrors.WithLabelValues(t.metricName).Inc() 232 | } else if rewritten { 233 | rewrite.WithLabelValues(t.metricName).Inc() 234 | rewriteTime.WithLabelValues(t.metricName).Observe(duration.Seconds()) 235 | } 236 | return updatedRef, err 237 | } 238 | 239 | func (t *ruleTransformer) doRewriteImage(imageRef string) (rewritten bool, updatedRef string, err error) { 240 | registry, err := RegistryFromImageRef(imageRef) 241 | if err != nil { 242 | return false, "", err 243 | } 244 | // shenanigans to get a fully normalized ref, e.g 'ubuntu' -> 'docker.io/library/ubuntu:latest' 245 | normalizedRef, err := ReplaceRegistryInImageRef(imageRef, registry) 246 | if err != nil { 247 | return false, "", err 248 | } 249 | 250 | if t.findMatch(normalizedRef) && !t.anyExclusion(normalizedRef) { 251 | updatedRef, err = ReplaceRegistryInImageRef(imageRef, t.rule.Replace) 252 | return true, updatedRef, err 253 | } 254 | 255 | return false, imageRef, nil 256 | } 257 | 258 | func (t *ruleTransformer) findMatch(imageRef string) bool { 259 | for _, rule := range t.matches { 260 | if rule.MatchString(imageRef) { 261 | return true 262 | } 263 | } 264 | return false 265 | } 266 | 267 | func (t *ruleTransformer) anyExclusion(imageRef string) bool { 268 | for _, rule := range t.excludes { 269 | if rule.MatchString(imageRef) { 270 | return true 271 | } 272 | } 273 | return false 274 | } 275 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/indeedeng-alpha/harbor-container-webhook/internal/config" 9 | "github.com/indeedeng-alpha/harbor-container-webhook/internal/webhook" 10 | 11 | admissionv1 "k8s.io/api/admission/v1" 12 | admissionv1beta1 "k8s.io/api/admission/v1beta1" 13 | 14 | "k8s.io/apimachinery/pkg/runtime" 15 | 16 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 17 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 18 | "k8s.io/client-go/rest" 19 | 20 | ctrl "sigs.k8s.io/controller-runtime" 21 | "sigs.k8s.io/controller-runtime/pkg/healthz" 22 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 23 | metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" 24 | ctrlwebhook "sigs.k8s.io/controller-runtime/pkg/webhook" 25 | "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 26 | ) 27 | 28 | var ( 29 | scheme = runtime.NewScheme() 30 | setupLog = ctrl.Log.WithName("setup") 31 | ) 32 | 33 | func init() { 34 | _ = clientgoscheme.AddToScheme(scheme) 35 | _ = admissionv1.AddToScheme(scheme) 36 | _ = admissionv1beta1.AddToScheme(scheme) 37 | // +kubebuilder:scaffold:scheme 38 | } 39 | 40 | func main() { 41 | ctrl.SetLogger(zap.New(zap.UseDevMode(true))) 42 | opts := zap.Options{} 43 | opts.BindFlags(flag.CommandLine) 44 | 45 | var configPath string 46 | var kubeClientBurst int 47 | var kubeClientQPS float64 48 | var kubeClientlazyRemap bool 49 | flag.StringVar(&configPath, "config", "", "path to the config for the harbor-container-webhook") 50 | flag.IntVar(&kubeClientBurst, "kube-client-burst", rest.DefaultBurst, "Burst value for kubernetes client.") 51 | flag.Float64Var(&kubeClientQPS, "kube-client-qps", float64(rest.DefaultQPS), "QPS value for kubernetes client.") 52 | flag.BoolVar(&kubeClientlazyRemap, "kube-client-lazy-remap", false, "Deprecated. Has no effect.") 53 | flag.Parse() 54 | 55 | conf, err := config.LoadConfiguration(configPath) 56 | if err != nil { 57 | setupLog.Error(err, "unable to read config from "+configPath) 58 | os.Exit(1) 59 | } 60 | if len(conf.Rules) == 0 { 61 | setupLog.Error(err, "no proxy rules configured from "+configPath) 62 | os.Exit(1) 63 | } 64 | setupLog.Info("webhook namespace: " + conf.Rules[0].Namespace) 65 | 66 | restConfig := ctrl.GetConfigOrDie() 67 | restConfig.QPS = float32(kubeClientQPS) 68 | restConfig.Burst = kubeClientBurst 69 | restConfig.UserAgent = "harbor-container-webhook" 70 | 71 | mgr, err := ctrl.NewManager(restConfig, ctrl.Options{ 72 | Scheme: scheme, 73 | Metrics: metricsserver.Options{ 74 | BindAddress: conf.MetricsAddr, 75 | }, 76 | WebhookServer: ctrlwebhook.NewServer(ctrlwebhook.Options{ 77 | Port: conf.Port, 78 | CertDir: conf.CertDir, 79 | }), 80 | HealthProbeBindAddress: conf.HealthAddr, 81 | LeaderElection: false, 82 | }) 83 | if err != nil { 84 | setupLog.Error(err, "unable to start harbor-container-webhook") 85 | os.Exit(1) 86 | } 87 | 88 | transformers, err := webhook.MakeTransformers(conf.Rules, mgr.GetClient()) 89 | if err != nil { 90 | setupLog.Error(err, "unable to start harbor-container-webhook") 91 | os.Exit(1) 92 | } 93 | 94 | if err := mgr.AddHealthzCheck("health-ping", healthz.Ping); err != nil { 95 | setupLog.Error(err, "Unable add a liveness check to harbor-container-webhook") 96 | os.Exit(1) 97 | } 98 | if err := mgr.AddReadyzCheck("ready-ping", healthz.Ping); err != nil { 99 | setupLog.Error(err, "Unable add a readiness check to harbor-container-webhook") 100 | os.Exit(1) 101 | } 102 | 103 | mutate := webhook.PodContainerProxier{ 104 | Client: mgr.GetClient(), 105 | Decoder: admission.NewDecoder(scheme), 106 | Transformers: transformers, 107 | Verbose: conf.Verbose, 108 | 109 | KubeClientQPS: float32(kubeClientQPS), 110 | KubeClientBurst: kubeClientBurst, 111 | } 112 | setupLog.Info(fmt.Sprintf("kube client configured for %f.2 QPS, %d Burst", float32(kubeClientQPS), kubeClientBurst)) 113 | 114 | mgr.GetWebhookServer().Register("/webhook-v1-pod", &ctrlwebhook.Admission{Handler: &mutate}) 115 | 116 | setupLog.Info("starting harbor-container-webhook") 117 | if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { 118 | setupLog.Error(err, "problem running harbor-container-webhook") 119 | os.Exit(1) 120 | } 121 | } 122 | --------------------------------------------------------------------------------