├── .github ├── FUNDING.yml ├── dependabot.yml ├── release-drafter.yml ├── release.yml └── workflows │ ├── auto-approve.yml │ ├── auto-merge.yml │ ├── awaiting-reply.yml │ ├── codeql-analysis.yml │ ├── deploy.yml │ ├── docs.yml │ ├── pre-commit.yml │ ├── release-drafter.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── .k8s-image-swapper.yml ├── .pre-commit-config.yaml ├── .releaserc ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── root.go ├── deploy └── k8s-image-swapper │ └── README.md ├── docs ├── configuration.md ├── faq.md ├── getting-started.md ├── img │ ├── gcp_artifact_registry.png │ ├── indiana.gif │ └── k8s-image-swapper_explainer.gif ├── index.md └── overrides │ └── main.html ├── go.mod ├── go.sum ├── main.go ├── mkdocs.yml ├── package-lock.json ├── package.json ├── pkg ├── config │ ├── config.go │ └── config_test.go ├── registry │ ├── client.go │ ├── ecr.go │ ├── ecr_test.go │ ├── gar.go │ ├── gar_test.go │ └── inmemory.go ├── secrets │ ├── dummy.go │ ├── dummy_test.go │ ├── kubernetes.go │ ├── kubernetes_test.go │ └── provider.go ├── types │ ├── types.go │ └── types_test.go └── webhook │ ├── image_copier.go │ ├── image_copier_test.go │ ├── image_swapper.go │ └── image_swapper_test.go └── test ├── curl.sh ├── e2e_test.go ├── kind-with-registry.sh ├── kind.yaml └── requests ├── admissionreview-imagepullsecrets.json └── admissionreview-simple.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [estahn] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | otechie: # Replace with a single Otechie username 11 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | target-branch: "main" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "docker" 10 | directory: "/" 11 | target-branch: "main" 12 | schedule: 13 | interval: "daily" 14 | 15 | - package-ecosystem: "gomod" 16 | directory: "/" 17 | target-branch: "main" 18 | schedule: 19 | interval: "weekly" 20 | 21 | - package-ecosystem: "npm" 22 | directory: "/" 23 | target-branch: "main" 24 | schedule: 25 | interval: "weekly" 26 | versioning-strategy: increase 27 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'feature' 7 | - 'enhancement' 8 | - title: '🐛 Bug Fixes' 9 | labels: 10 | - 'fix' 11 | - 'bugfix' 12 | - 'bug' 13 | - title: '📝 Documentation' 14 | label: 'docs' 15 | - title: '🧰 Maintenance' 16 | label: 'chore' 17 | - title: '⬆️ Dependencies' 18 | collapse-after: 3 19 | labels: 20 | - 'dependencies' 21 | - title: '👷 Continuous Integration' 22 | collapse-after: 3 23 | labels: 24 | - 'ci' 25 | 26 | exclude-labels: 27 | - 'ignore-for-release' 28 | 29 | replacers: 30 | - search: '/^(fix|feat|ci|build)(\(.+?\))?: /g' 31 | replace: '' 32 | 33 | template: | 34 | ## What's Changed 35 | 36 | $CHANGES 37 | 38 | version-resolver: 39 | major: 40 | labels: 41 | - 'type: breaking' 42 | minor: 43 | labels: 44 | - 'enhancement' 45 | patch: 46 | labels: 47 | - 'bugfix' 48 | - 'maintenance' 49 | - 'docs' 50 | - 'dependencies' 51 | - 'security' 52 | 53 | autolabeler: 54 | - label: 'bugfix' 55 | title: 56 | - '/fix:/i' 57 | - label: 'enhancement' 58 | title: 59 | - '/feat:/i' 60 | - label: 'docs' 61 | title: 62 | - '/docs:/i' 63 | - label: 'chore' 64 | title: 65 | - '/chore:/i' 66 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | authors: 6 | - octocat 7 | categories: 8 | - title: 🛠 Breaking Changes 9 | labels: 10 | - breaking-change 11 | - title: '🚀 Features' 12 | labels: 13 | - 'feature' 14 | - 'enhancement' 15 | - title: '🐛 Bug Fixes' 16 | labels: 17 | - 'fix' 18 | - 'bugfix' 19 | - 'bug' 20 | - title: '📝 Documentation' 21 | label: 'docs' 22 | - title: '🧰 Maintenance' 23 | label: 'chore' 24 | - title: '⬆️ Dependencies' 25 | collapse-after: 3 26 | labels: 27 | - 'dependencies' 28 | - title: '👷 Continuous Integration' 29 | collapse-after: 3 30 | labels: 31 | - 'ci' 32 | - title: Other Changes 33 | labels: 34 | - "*" 35 | -------------------------------------------------------------------------------- /.github/workflows/auto-approve.yml: -------------------------------------------------------------------------------- 1 | name: Auto approve 2 | 3 | on: 4 | pull_request_target 5 | 6 | jobs: 7 | auto-approve: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: hmarr/auto-approve-action@v4 11 | if: github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]' 12 | with: 13 | github-token: "${{ secrets.GITHUB_TOKEN }}" 14 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Auto-Merge 2 | on: pull_request 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | automerge: 10 | runs-on: ubuntu-latest 11 | if: github.actor == 'dependabot[bot]' 12 | steps: 13 | - uses: peter-evans/enable-pull-request-automerge@v3 14 | with: 15 | pull-request-number: ${{ github.event.pull_request.number }} 16 | merge-method: squash 17 | -------------------------------------------------------------------------------- /.github/workflows/awaiting-reply.yml: -------------------------------------------------------------------------------- 1 | on: 2 | issue_comment: 3 | types: [created] 4 | 5 | jobs: 6 | awaiting_reply: 7 | runs-on: ubuntu-latest 8 | name: Toggle label upon reply 9 | steps: 10 | - name: Toggle label 11 | uses: jd-0001/gh-action-toggle-awaiting-reply-label@v2.1.2 12 | with: 13 | label: awaiting-reply 14 | exclude-members: estahn 15 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | paths-ignore: 18 | - 'docs/**' 19 | - 'mkdocs.yml' 20 | pull_request: 21 | # The branches below must be a subset of the branches above 22 | branches: [ main ] 23 | schedule: 24 | - cron: '42 5 * * 5' 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-latest 30 | 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | language: [ 'go' ] 35 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 36 | # Learn more: 37 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | - name: Set up Go 44 | uses: actions/setup-go@v5 45 | with: 46 | go-version-file: 'go.mod' 47 | check-latest: true 48 | cache: true 49 | 50 | # Initializes the CodeQL tools for scanning. 51 | - name: Initialize CodeQL 52 | uses: github/codeql-action/init@v3 53 | with: 54 | languages: ${{ matrix.language }} 55 | # If you wish to specify custom queries, you can do so here or in a config file. 56 | # By default, queries listed here will override any specified in a config file. 57 | # Prefix the list here with "+" to use these queries and those in the config file. 58 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 59 | 60 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 61 | # If this step fails, then you should remove it and run the build manually (see below) 62 | - name: Autobuild 63 | uses: github/codeql-action/autobuild@v3 64 | 65 | # ℹ️ Command-line programs to run using the OS shell. 66 | # 📚 https://git.io/JvXDl 67 | 68 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 69 | # and modify them (or add more) to build your code if your project 70 | # uses a compiled language 71 | 72 | #- run: | 73 | # make bootstrap 74 | # make release 75 | 76 | - name: Perform CodeQL Analysis 77 | uses: github/codeql-action/analyze@v3 78 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | forRef: 7 | required: true 8 | type: string 9 | workflow_dispatch: 10 | inputs: 11 | forRef: 12 | description: 'Branch, SHA or Tag to release' 13 | required: false 14 | type: string 15 | 16 | permissions: 17 | contents: write 18 | packages: write 19 | 20 | env: 21 | REGISTRY: ghcr.io 22 | IMAGE_NAME: ${{ github.repository }} 23 | 24 | jobs: 25 | generate-artifacts: 26 | name: Generate artifacts 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | with: 32 | ref: ${{ inputs.forRef }} 33 | 34 | - name: Unshallow 35 | run: git fetch --prune --unshallow 36 | 37 | - name: Ensure release-notes exists 38 | run: touch /tmp/release-notes.md 39 | 40 | - name: Set up QEMU 41 | uses: docker/setup-qemu-action@v3 42 | 43 | - name: Set up Docker Buildx 44 | id: buildx 45 | uses: docker/setup-buildx-action@v3 46 | 47 | - name: Install dependencies 48 | run: sudo apt-get update && sudo apt-get install -y libdevmapper-dev libbtrfs-dev 49 | 50 | - name: Set up Go 51 | uses: actions/setup-go@v5 52 | with: 53 | go-version-file: 'go.mod' 54 | check-latest: true 55 | cache: true 56 | 57 | - name: Login to github registry 58 | uses: docker/login-action@v3.4.0 59 | with: 60 | registry: ${{ env.REGISTRY }} 61 | username: ${{ github.actor }} 62 | password: ${{ secrets.GITHUB_TOKEN }} 63 | 64 | - name: Run GoReleaser 65 | uses: goreleaser/goreleaser-action@v6.3.0 66 | with: 67 | version: latest 68 | args: release --clean 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | #name: Publish docs 2 | #on: 3 | # workflow_dispatch: 4 | # push: 5 | # branches: 6 | # - main 7 | # paths: 8 | # - 'docs/**' 9 | # - mkdocs.yml 10 | # 11 | #jobs: 12 | # build: 13 | # name: Deploy docs 14 | # runs-on: ubuntu-latest 15 | # steps: 16 | # - name: Checkout main 17 | # uses: actions/checkout@v3 18 | # with: 19 | # fetch-depth: 0 20 | # 21 | # - uses: actions/setup-python@v4.5.0 22 | # with: 23 | # python-version: '3.x' 24 | # 25 | # - name: Install mkdocs 26 | # run: pip install --upgrade pip && pip install mike mkdocs mkdocs-minify-plugin mkdocs-markdownextradata-plugin mkdocs-macros-plugin pymdown-extensions mkdocs-material 27 | # 28 | # - run: git config user.name 'github-actions[bot]' && git config user.email 'github-actions[bot]@users.noreply.github.com' 29 | # 30 | # - name: Publish docs 31 | # run: mkdocs gh-deploy 32 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | 12 | # don't run this on the master branch 13 | if: github.ref != 'refs/heads/master' 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - uses: actions/setup-python@v5.6.0 20 | with: 21 | python-version: '3.x' 22 | - uses: actions/setup-go@v5 23 | with: 24 | go-version-file: 'go.mod' 25 | check-latest: true 26 | cache: true 27 | - name: Install dependencies 28 | run: sudo apt-get update && sudo apt-get install -y libdevmapper-dev libbtrfs-dev 29 | - uses: pre-commit/action@v3.0.1 30 | with: 31 | token: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | # branches to consider in the event; optional, defaults to all 7 | branches: 8 | - main 9 | # pull_request event is required only for autolabeler 10 | pull_request: 11 | # Only following types are handled by the action, but one can default to all as well 12 | types: [opened, reopened, synchronize] 13 | # pull_request_target event is required for autolabeler to support PRs from forks 14 | pull_request_target: 15 | types: [opened, reopened, synchronize] 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | update_release_draft: 22 | permissions: 23 | # write permission is required to create a github release 24 | contents: write 25 | # write permission is required for autolabeler 26 | # otherwise, read permission is required at least 27 | pull-requests: write 28 | runs-on: ubuntu-latest 29 | steps: 30 | # (Optional) GitHub Enterprise requires GHE_HOST variable set 31 | #- name: Set GHE_HOST 32 | # run: | 33 | # echo "GHE_HOST=${GITHUB_SERVER_URL##https:\/\/}" >> $GITHUB_ENV 34 | 35 | # Drafts your next Release notes as Pull Requests are merged into "master" 36 | - uses: release-drafter/release-drafter@v6 37 | # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml 38 | # with: 39 | # config-name: my-config.yml 40 | # disable-autolabeler: true 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | # Release patches and secruity updates on a schedule 5 | schedule: 6 | - cron: "0 0 1 * *" 7 | 8 | jobs: 9 | release: 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | runs-on: ubuntu-latest 14 | outputs: 15 | tag_name: ${{ steps.release-drafter.outputs.tag_name }} 16 | steps: 17 | - id: release-drafter 18 | uses: release-drafter/release-drafter@v6 19 | with: 20 | publish: true 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | deploy: 25 | needs: [release] 26 | uses: ./.github/workflows/deploy.yml 27 | secrets: inherit 28 | permissions: 29 | packages: write 30 | contents: write 31 | with: 32 | forRef: ${{ needs.release.outputs.tag_name }} 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | - 'releases/*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - 'mkdocs.yml' 12 | 13 | concurrency: 14 | group: ${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | 19 | lint: 20 | name: Lint 21 | runs-on: ubuntu-latest 22 | steps: 23 | 24 | - name: Install dependencies 25 | run: sudo apt-get update && sudo apt-get install -y libdevmapper-dev libbtrfs-dev 26 | 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Set up Go 31 | uses: actions/setup-go@v5 32 | with: 33 | go-version-file: 'go.mod' 34 | check-latest: true 35 | cache: true 36 | 37 | - name: golangci-lint 38 | uses: golangci/golangci-lint-action@v6.5.1 39 | with: 40 | version: latest 41 | args: --timeout=5m 42 | 43 | test: 44 | name: Test 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Install dependencies 48 | run: sudo apt-get update && sudo apt-get install -y libdevmapper-dev libbtrfs-dev 49 | 50 | - name: Checkout 51 | uses: actions/checkout@v4 52 | 53 | - name: Set up Go 54 | uses: actions/setup-go@v5 55 | with: 56 | go-version-file: 'go.mod' 57 | check-latest: true 58 | cache: true 59 | 60 | - uses: actions/cache@v4.2.3 61 | with: 62 | path: | 63 | ~/go/pkg/mod # Module download cache 64 | ~/.cache/go-build # Build cache (Linux) 65 | ~/Library/Caches/go-build # Build cache (Mac) 66 | '%LocalAppData%\go-build' # Build cache (Windows) 67 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 68 | restore-keys: | 69 | ${{ runner.os }}-go- 70 | 71 | - name: Test 72 | run: go test -coverprofile cover.out ./... 73 | 74 | - uses: codecov/codecov-action@v5 75 | with: 76 | token: ${{ secrets.CODECOV_TOKEN }} 77 | file: ./cover.out # optional 78 | fail_ci_if_error: true 79 | verbose: true 80 | 81 | image-scan: 82 | name: Image Scan 83 | runs-on: ubuntu-latest 84 | steps: 85 | 86 | - name: Install dependencies 87 | run: sudo apt-get update && sudo apt-get install -y libdevmapper-dev libbtrfs-dev 88 | 89 | - name: Checkout 90 | uses: actions/checkout@v4 91 | 92 | - name: Set up QEMU 93 | uses: docker/setup-qemu-action@v3 94 | 95 | - name: Set up Docker Buildx 96 | id: buildx 97 | uses: docker/setup-buildx-action@v3 98 | 99 | - name: Unshallow 100 | run: git fetch --prune --unshallow 101 | 102 | - name: Set up Go 103 | uses: actions/setup-go@v5 104 | with: 105 | go-version-file: 'go.mod' 106 | check-latest: true 107 | cache: true 108 | 109 | - uses: actions/cache@v4.2.3 110 | with: 111 | path: | 112 | ~/go/pkg/mod # Module download cache 113 | ~/.cache/go-build # Build cache (Linux) 114 | ~/Library/Caches/go-build # Build cache (Mac) 115 | '%LocalAppData%\go-build' # Build cache (Windows) 116 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 117 | restore-keys: | 118 | ${{ runner.os }}-go- 119 | 120 | - name: Run GoReleaser 121 | uses: goreleaser/goreleaser-action@v6.3.0 122 | with: 123 | version: latest 124 | args: release --clean --skip=validate,publish 125 | env: 126 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 127 | 128 | - name: Scan image 129 | uses: anchore/scan-action@v6 130 | id: scan 131 | with: 132 | image: "ghcr.io/estahn/k8s-image-swapper:latest" 133 | fail-build: false 134 | acs-report-enable: true 135 | 136 | - name: Upload Anchore scan SARIF report 137 | uses: github/codeql-action/upload-sarif@v3 138 | with: 139 | sarif_file: ${{ steps.scan.outputs.sarif }} 140 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | .idea/ 18 | coverage.txt 19 | k8s-image-swapper 20 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | env: 2 | - GO111MODULE=on 3 | 4 | gomod: 5 | proxy: true 6 | 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | goos: 11 | #- windows 12 | - darwin 13 | - linux 14 | goarch: 15 | - amd64 16 | - arm64 17 | mod_timestamp: '{{ .CommitTimestamp }}' 18 | flags: 19 | - -trimpath 20 | ldflags: 21 | - -s -w 22 | 23 | dockers: 24 | - image_templates: 25 | - "ghcr.io/estahn/k8s-image-swapper:latest-amd64" 26 | - "ghcr.io/estahn/k8s-image-swapper:{{ .Version }}-amd64" 27 | - "ghcr.io/estahn/k8s-image-swapper:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-amd64" 28 | - "ghcr.io/estahn/k8s-image-swapper:{{ .Major }}.{{ .Minor }}-amd64" 29 | - "ghcr.io/estahn/k8s-image-swapper:{{ .Major }}-amd64" 30 | use: buildx 31 | dockerfile: Dockerfile 32 | goarch: amd64 33 | build_flag_templates: 34 | - "--pull" 35 | - "--label=org.opencontainers.image.created={{.Date}}" 36 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 37 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 38 | - "--label=org.opencontainers.image.version={{.Version}}" 39 | - "--build-arg=VERSION={{.Version}}" 40 | - "--build-arg=BUILD_DATE={{.Date}}" 41 | - "--build-arg=VCS_REF={{.FullCommit}}" 42 | - "--platform=linux/amd64" 43 | - image_templates: 44 | - "ghcr.io/estahn/k8s-image-swapper:latest-arm64v8" 45 | - "ghcr.io/estahn/k8s-image-swapper:{{ .Version }}-arm64v8" 46 | - "ghcr.io/estahn/k8s-image-swapper:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-arm64v8" 47 | - "ghcr.io/estahn/k8s-image-swapper:{{ .Major }}.{{ .Minor }}-arm64v8" 48 | - "ghcr.io/estahn/k8s-image-swapper:{{ .Major }}-arm64v8" 49 | use: buildx 50 | dockerfile: Dockerfile 51 | goarch: arm64 52 | build_flag_templates: 53 | - "--pull" 54 | - "--label=org.opencontainers.image.created={{.Date}}" 55 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 56 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 57 | - "--label=org.opencontainers.image.version={{.Version}}" 58 | - "--build-arg=VERSION={{.Version}}" 59 | - "--build-arg=BUILD_DATE={{.Date}}" 60 | - "--build-arg=VCS_REF={{.FullCommit}}" 61 | - "--platform=linux/arm64/v8" 62 | 63 | docker_manifests: 64 | - name_template: ghcr.io/estahn/k8s-image-swapper:latest 65 | image_templates: 66 | - ghcr.io/estahn/k8s-image-swapper:latest-amd64 67 | - ghcr.io/estahn/k8s-image-swapper:latest-arm64v8 68 | - name_template: ghcr.io/estahn/k8s-image-swapper:{{ .Version }} 69 | image_templates: 70 | - ghcr.io/estahn/k8s-image-swapper:{{ .Version }}-amd64 71 | - ghcr.io/estahn/k8s-image-swapper:{{ .Version }}-arm64v8 72 | - name_template: ghcr.io/estahn/k8s-image-swapper:{{ .Major }}.{{ .Minor }}.{{ .Patch }} 73 | image_templates: 74 | - ghcr.io/estahn/k8s-image-swapper:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-amd64 75 | - ghcr.io/estahn/k8s-image-swapper:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-arm64v8 76 | - name_template: ghcr.io/estahn/k8s-image-swapper:{{ .Major }}.{{ .Minor }} 77 | image_templates: 78 | - ghcr.io/estahn/k8s-image-swapper:{{ .Major }}.{{ .Minor }}-amd64 79 | - ghcr.io/estahn/k8s-image-swapper:{{ .Major }}.{{ .Minor }}-arm64v8 80 | - name_template: ghcr.io/estahn/k8s-image-swapper:{{ .Major }} 81 | image_templates: 82 | - ghcr.io/estahn/k8s-image-swapper:{{ .Major }}-amd64 83 | - ghcr.io/estahn/k8s-image-swapper:{{ .Major }}-arm64v8 84 | 85 | release: 86 | prerelease: auto 87 | 88 | changelog: 89 | filters: 90 | exclude: 91 | - '^docs:' 92 | - '^chore:' 93 | 94 | archives: 95 | - format: binary 96 | -------------------------------------------------------------------------------- /.k8s-image-swapper.yml: -------------------------------------------------------------------------------- 1 | dryRun: true 2 | 3 | logLevel: trace 4 | logFormat: console 5 | 6 | # imageSwapPolicy defines the mutation strategy used by the webhook. 7 | # - always: Will always swap the image regardless of the image existence in the target registry. 8 | # This can result in pods ending in state ImagePullBack if images fail to be copied to the target registry. 9 | # - exists: Only swaps the image if it exits in the target registry. 10 | # This can result in pods pulling images from the source registry, e.g. the first pod pulls 11 | # from source registry, subsequent pods pull from target registry. 12 | imageSwapPolicy: exists 13 | 14 | # imageCopyPolicy defines the image copy strategy used by the webhook. 15 | # - delayed: Submits the copy job to a process queue and moves on. 16 | # - immediate: Submits the copy job to a process queue and waits for it to finish (deadline 8s). 17 | # - force: Attempts to immediately copy the image (deadline 8s). 18 | # - none: Do not copy the image. 19 | imageCopyPolicy: delayed 20 | 21 | source: 22 | # Filters provide control over what pods will be processed. 23 | # By default all pods will be processed. If a condition matches, the pod will NOT be processed. 24 | # For query language details see https://jmespath.org/ 25 | filters: 26 | # Do not process if namespace equals "kube-system" 27 | - jmespath: "obj.metadata.namespace == 'kube-system'" 28 | 29 | # Only process if namespace equals "playground" 30 | #- jmespath: "obj.metadata.namespace != 'playground'" 31 | 32 | # Only process if namespace ends with "-dev" 33 | #- jmespath: "ends_with(obj.metadata.namespace,'-dev')" 34 | 35 | # registries: 36 | # dockerio: 37 | # username: 38 | # password: 39 | 40 | target: 41 | type: aws 42 | aws: 43 | accountId: 123456789 44 | region: ap-southeast-2 45 | role: arn:aws:iam::123456789012:role/roleName 46 | ecrOptions: 47 | tags: 48 | - key: CreatedBy 49 | value: k8s-image-swapper 50 | - key: AnotherTag 51 | value: another-tag 52 | imageTagMutability: MUTABLE 53 | imageScanningConfiguration: 54 | imageScanOnPush: true 55 | encryptionConfiguration: 56 | encryptionType: AES256 57 | kmsKey: string 58 | accessPolicy: | 59 | { 60 | "Version": "2008-10-17", 61 | "Statement": [ 62 | { 63 | "Sid": "AllowCrossAccountPull", 64 | "Effect": "Allow", 65 | "Principal": { 66 | "AWS": "*" 67 | }, 68 | "Action": [ 69 | "ecr:GetDownloadUrlForLayer", 70 | "ecr:BatchGetImage", 71 | "ecr:BatchCheckLayerAvailability" 72 | ], 73 | "Condition": { 74 | "StringEquals": { 75 | "aws:PrincipalOrgID": [ 76 | "o-xxxxxxxx" 77 | ] 78 | } 79 | } 80 | } 81 | ] 82 | } 83 | 84 | lifecyclePolicy: | 85 | { 86 | "rules": [ 87 | { 88 | "rulePriority": 1, 89 | "description": "Rule 1", 90 | "selection": { 91 | "tagStatus": "any", 92 | "countType": "imageCountMoreThan", 93 | "countNumber": 1 94 | }, 95 | "action": { 96 | "type": "expire" 97 | } 98 | } 99 | ] 100 | } 101 | # dockerio: 102 | # quayio: 103 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: 38b88246ccc552bffaaf54259d064beeee434539 # v4.0.1 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: check-added-large-files 7 | - id: check-json 8 | - id: pretty-format-json 9 | args: ['--autofix'] 10 | exclude: package-lock.json 11 | - id: check-merge-conflict 12 | - id: check-symlinks 13 | - id: check-yaml 14 | exclude: 'hack/charts/.*\.yaml' 15 | - id: detect-private-key 16 | - id: check-merge-conflict 17 | - id: check-executables-have-shebangs 18 | - id: end-of-file-fixer 19 | - id: mixed-line-ending 20 | #- repo: https://github.com/thlorenz/doctoc 21 | # rev: v2.0.0 22 | # hooks: 23 | # - id: doctoc 24 | # args: ['--title', '## Table of Contents'] 25 | - repo: https://github.com/golangci/golangci-lint 26 | rev: v1.55.2 27 | hooks: 28 | - id: golangci-lint 29 | args: ['--timeout', '5m'] 30 | - repo: https://github.com/dnephin/pre-commit-golang 31 | rev: ac0f6582d2484b3aa90b05d568e70f9f3c1374c7 # v0.0.17 32 | hooks: 33 | - id: go-mod-tidy 34 | - id: go-fmt 35 | - id: go-imports 36 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | --- 2 | #verifyConditions: ['@semantic-release/github'] 3 | #prepare: [] 4 | #publish: ['@semantic-release/github'] 5 | #success: ['@semantic-release/github'] 6 | #fail: ['@semantic-release/github'] 7 | plugins: 8 | - "@semantic-release/commit-analyzer" 9 | - "@semantic-release/release-notes-generator" 10 | - "@semantic-release/changelog" 11 | - "@semantic-release/github" 12 | - "@semantic-release/git" 13 | - - "@semantic-release/exec" 14 | - generateNotesCmd: | 15 | echo "${nextRelease.notes}" > /tmp/release-notes.md 16 | verifyReleaseCmd: | 17 | echo "${nextRelease.version}" > /tmp/next-release-version.txt 18 | 19 | branch: main 20 | branches: 21 | - '+([0-9])?(.{+([0-9]),x}).x' 22 | - 'main' 23 | - 'next' 24 | - 'next-major' 25 | - {name: 'beta', prerelease: true} 26 | - {name: 'alpha', prerelease: true} 27 | 28 | analyzeCommits: 29 | - path: "@semantic-release/commit-analyzer" 30 | releaseRules: 31 | - type: "build" 32 | scope: "deps" 33 | release: "patch" 34 | 35 | generateNotes: 36 | - path: "@semantic-release/release-notes-generator" 37 | preset: "conventionalcommits" 38 | presetConfig: 39 | types: 40 | - { type: 'feat', section: ':tada: Features' } 41 | - { type: 'feature', section: ':tada: Features' } 42 | - { type: 'fix', section: ':bug: Bug Fixes' } 43 | - { type: 'perf', section: ':zap: Performance Improvements' } 44 | - { type: 'revert', section: ':rewind: Reverts' } 45 | - { type: 'docs', section: ':memo: Documentation', hidden: false } 46 | - { type: 'style', section: 'Styles', hidden: true } 47 | - { type: 'chore', section: 'Miscellaneous Chores', hidden: true } 48 | - { type: 'refactor', section: 'Code Refactoring', hidden: true } 49 | - { type: 'test', section: ':test_tube: Tests', hidden: true } 50 | - { type: 'build', scope: 'deps', section: ':arrow_up: Dependencies' } 51 | - { type: 'build', section: ':construction_worker: Build System' } 52 | - { type: 'ci', section: 'Continuous Integration', hidden: true } 53 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [enrico.stahn@gmail.com](mailto:enrico.stahn@gmail.com). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | By participating to this project, you agree to abide our 4 | [code of conduct](/CODE_OF_CONDUCT.md). 5 | 6 | ## Setup your machine 7 | 8 | `k8s-image-swapper` is written in [Go](https://golang.org/). 9 | 10 | Prerequisites: 11 | 12 | - `make` 13 | - [Go 1.16+](https://golang.org/doc/install) 14 | - [golangci-lint](https://golangci-lint.run/usage/install/#local-installation) 15 | - [Docker](https://www.docker.com/) (or [Podman](https://podman.io/)) 16 | - [kind](https://kind.sigs.k8s.io/) 17 | - [pre-commit](https://pre-commit.com/) (optional) 18 | - [ngrok](https://ngrok.com/) (optional) 19 | 20 | Clone `k8s-image-swapper` anywhere: 21 | 22 | ```sh 23 | git clone git@github.com:estahn/k8s-image-swapper.git 24 | ``` 25 | 26 | Install the build and lint dependencies: 27 | 28 | ```sh 29 | make setup 30 | ``` 31 | 32 | A good way of making sure everything is all right is running the test suite: 33 | 34 | ```sh 35 | make test 36 | ``` 37 | 38 | ## Test your change 39 | 40 | You can create a branch for your changes and try to build from the source as you go: 41 | 42 | ```sh 43 | make test 44 | ``` 45 | 46 | When you are satisfied with the changes, we suggest you run: 47 | 48 | ```sh 49 | make fmt lint test 50 | ``` 51 | 52 | Which runs all the linters and tests. 53 | 54 | ## Create a commit 55 | 56 | Commit messages should be well formatted, and to make that "standardized", we 57 | are using Conventional Commits. 58 | 59 | You can follow the documentation on 60 | [their website](https://www.conventionalcommits.org). 61 | 62 | ## Submit a pull request 63 | 64 | Push your branch to your `k8s-image-swapper` fork and open a pull request against the 65 | main branch. 66 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #FROM quay.io/skopeo/stable:v1.2.0 AS skopeo 2 | #FROM gcr.io/distroless/base-debian10 3 | #FROM debian:10 4 | #COPY --from=skopeo /usr/bin/skopeo /skopeo 5 | 6 | # TODO: Using alpine for now due to easier installation of skopeo 7 | # Will use distroless after incorporating skopeo into the webhook directly 8 | FROM alpine:3.21.3 9 | RUN ["apk", "add", "--no-cache", "--repository=http://dl-cdn.alpinelinux.org/alpine/edge/community", "skopeo>=1.2.0"] 10 | 11 | COPY k8s-image-swapper / 12 | 13 | ENTRYPOINT ["/k8s-image-swapper"] 14 | 15 | ARG BUILD_DATE 16 | ARG VCS_REF 17 | 18 | LABEL maintainer="k8s-image-swapper " \ 19 | org.opencontainers.image.title="k8s-image-swapper" \ 20 | org.opencontainers.image.description="Mirror images into your own registry and swap image references automatically." \ 21 | org.opencontainers.image.url="https://github.com/estahn/k8s-image-swapper" \ 22 | org.opencontainers.image.source="https://github.com/estahn/k8s-image-swapper" \ 23 | org.opencontainers.image.vendor="estahn" \ 24 | org.label-schema.schema-version="1.0" \ 25 | org.label-schema.name="k8s-image-swapper" \ 26 | org.label-schema.description="Mirror images into your own registry and swap image references automatically." \ 27 | org.label-schema.url="https://github.com/estahn/k8s-image-swapper" \ 28 | org.label-schema.vcs-url="git@github.com:estahn/k8s-image-swapper.git" \ 29 | org.label-schema.vendor="estahn" \ 30 | org.opencontainers.image.revision="$VCS_REF" \ 31 | org.opencontainers.image.created="$BUILD_DATE" \ 32 | org.label-schema.vcs-ref="$VCS_REF" \ 33 | org.label-schema.build-date="$BUILD_DATE" 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Enrico Stahn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCE_FILES?=./... 2 | TEST_PATTERN?=. 3 | TEST_OPTIONS?= 4 | 5 | .PHONY: help $(MAKECMDGOALS) 6 | .DEFAULT_GOAL := help 7 | 8 | export GO111MODULE := on 9 | export GOPROXY = https://proxy.golang.org,direct 10 | 11 | help: ## List targets & descriptions 12 | @cat Makefile* | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 13 | 14 | setup: ## Install dependencies 15 | go mod download 16 | go mod tidy 17 | 18 | test: ## Run tests 19 | LC_ALL=C go test $(TEST_OPTIONS) -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.txt $(SOURCE_FILES) -run $(TEST_PATTERN) -timeout=5m 20 | 21 | cover: test ## Run tests and open coverage report 22 | go tool cover -html=coverage.txt 23 | 24 | fmt: ## gofmt and goimports all go files 25 | gofmt -l -w . 26 | goimports -l -w . 27 | 28 | lint: ## Run linters 29 | golangci-lint run 30 | 31 | e2e: ## Run end-to-end tests 32 | go test -v -run TestHelmDeployment ./test 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Raiders of the Lost Ark 3 |

k8s-image-swapper

4 |

Mirror images into your own registry and swap image references automatically.

5 |

6 | 7 | --- 8 | 9 | `k8s-image-swapper` is a mutating webhook for Kubernetes, downloading images into your own registry and pointing the images to that new location. 10 | It is an alternative to a [docker pull-through proxy](https://docs.docker.com/registry/recipes/mirror/). 11 | 12 | **Amazon ECR** and **Google Container Registry** are currently supported. 13 | 14 | ## :zap: Benefits 15 | 16 | Using `k8s-image-swapper` will improve the overall availability, reliability, durability and resiliency of your 17 | Kubernetes cluster by keeping 3rd-party images mirrored into your own registry. 18 | 19 | `k8s-image-swapper` will transparently consolidate all images into a single registry without the need to adjust manifests 20 | therefore reducing the impact of external registry failures, rate limiting, network issues, change or removal of images 21 | while reducing data traffic and therefore cost. 22 | 23 | **TL;DR:** 24 | 25 | * Protect against: 26 | * external registry failure ([quay.io outage](https://www.reddit.com/r/devops/comments/f9kiej/quayio_is_experiencing_an_outage/)) 27 | * image pull rate limiting ([docker.io rate limits](https://www.docker.com/blog/scaling-docker-to-serve-millions-more-developers-network-egress/)) 28 | * accidental image changes 29 | * removal of images 30 | * Use in air-gaped environments without the need to change manifests 31 | * Reduce NAT ingress traffic/cost 32 | 33 | ## :book: Documentation 34 | 35 | A comprehensive guide on getting started and a list of configuration options can be found in the documentation. 36 | 37 | [![Documentation](https://img.shields.io/badge/Documentation-2FA4E7?style=for-the-badge&logo=ReadMe&logoColor=white)](https://estahn.github.io/k8s-image-swapper/index.html) 38 | 39 | ## :question: Community 40 | 41 | You have questions, need support and or just want to talk about `k8s-image-swapper`? 42 | 43 | Here are ways to get in touch with the community: 44 | 45 | [![Slack channel](https://img.shields.io/badge/Slack_Channel-4A154B?style=for-the-badge&logo=slack&logoColor=white)](http://slack.kubernetes.io/) 46 | [![GitHub Discussions](https://img.shields.io/badge/GITHUB_DISCUSSION-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/estahn/k8s-image-swapper/discussions) 47 | 48 | 49 | ## :heart_decoration: Sponsor 50 | 51 | Does your company use `k8s-image-swapper`? 52 | Help keep the project bug-free and feature rich by [sponsoring the project](https://github.com/sponsors/estahn). 53 | 54 | ## :office: Commercial Support 55 | 56 | Does your company require individual support or addition of features within a guaranteed timeframe? 57 | Contact me via [email](mailto:enrico.stahn@gmail.com) to discuss. 58 | 59 | ## :octocat: Badges 60 | 61 | [![Release](https://img.shields.io/github/release/estahn/k8s-image-swapper.svg?style=for-the-badge)](https://github.com/estahn/k8s-image-swapper/releases/latest) 62 | [![Artifact Hub](https://img.shields.io/badge/Artifact_Hub-417598?style=for-the-badge&logo=artifacthub&logoColor=white)](https://artifacthub.io/packages/helm/estahn/k8s-image-swapper) 63 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=for-the-badge)](/LICENSE.md) 64 | [![Codecov branch](https://img.shields.io/codecov/c/github/estahn/k8s-image-swapper/main.svg?style=for-the-badge)](https://codecov.io/gh/estahn/k8s-image-swapper) 65 | [![Go Doc](https://img.shields.io/badge/godoc-reference-blue.svg?style=for-the-badge)](http://godoc.org/github.com/estahn/k8s-image-swapper) 66 | 67 | ## :star2: Stargazers over time 68 | 69 | [![Stargazers over time](https://starchart.cc/estahn/k8s-image-swapper.svg)](https://starchart.cc/estahn/k8s-image-swapper) 70 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 Enrico Stahn 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "context" 26 | "fmt" 27 | "net/http" 28 | "os" 29 | "os/signal" 30 | "syscall" 31 | "time" 32 | 33 | "github.com/estahn/k8s-image-swapper/pkg/config" 34 | "github.com/estahn/k8s-image-swapper/pkg/registry" 35 | "github.com/estahn/k8s-image-swapper/pkg/secrets" 36 | "github.com/estahn/k8s-image-swapper/pkg/types" 37 | "github.com/estahn/k8s-image-swapper/pkg/webhook" 38 | homedir "github.com/mitchellh/go-homedir" 39 | "github.com/prometheus/client_golang/prometheus/promhttp" 40 | "github.com/rs/zerolog" 41 | "github.com/rs/zerolog/log" 42 | kwhhttp "github.com/slok/kubewebhook/v2/pkg/http" 43 | "github.com/spf13/cobra" 44 | "github.com/spf13/viper" 45 | "k8s.io/client-go/kubernetes" 46 | "k8s.io/client-go/rest" 47 | ) 48 | 49 | var cfgFile string 50 | var cfg *config.Config = &config.Config{} 51 | 52 | // rootCmd represents the base command when called without any subcommands 53 | var rootCmd = &cobra.Command{ 54 | Use: "k8s-image-swapper", 55 | Short: "Mirror images into your own registry and swap image references automatically.", 56 | Long: `Mirror images into your own registry and swap image references automatically. 57 | 58 | A mutating webhook for Kubernetes, pointing the images to a new location.`, 59 | // Uncomment the following line if your bare application 60 | // has an action associated with it: 61 | Run: func(cmd *cobra.Command, args []string) { 62 | //promReg := prometheus.NewRegistry() 63 | //metricsRec := metrics.NewPrometheus(promReg) 64 | log.Trace().Interface("config", cfg).Msg("config") 65 | 66 | // Create registry clients for source registries 67 | sourceRegistryClients := []registry.Client{} 68 | for _, reg := range cfg.Source.Registries { 69 | sourceRegistryClient, err := registry.NewClient(reg) 70 | if err != nil { 71 | log.Err(err).Msgf("error connecting to source registry at %s", reg.Domain()) 72 | os.Exit(1) 73 | } 74 | sourceRegistryClients = append(sourceRegistryClients, sourceRegistryClient) 75 | } 76 | 77 | // Create a registry client for private target registry 78 | targetRegistryClient, err := registry.NewClient(cfg.Target) 79 | if err != nil { 80 | log.Err(err).Msgf("error connecting to target registry at %s", cfg.Target.Domain()) 81 | os.Exit(1) 82 | } 83 | 84 | imageSwapPolicy, err := types.ParseImageSwapPolicy(cfg.ImageSwapPolicy) 85 | if err != nil { 86 | log.Err(err).Str("policy", cfg.ImageSwapPolicy).Msg("parsing image swap policy failed") 87 | } 88 | 89 | imageCopyPolicy, err := types.ParseImageCopyPolicy(cfg.ImageCopyPolicy) 90 | if err != nil { 91 | log.Err(err).Str("policy", cfg.ImageCopyPolicy).Msg("parsing image copy policy failed") 92 | } 93 | 94 | imageCopyDeadline := config.DefaultImageCopyDeadline 95 | if cfg.ImageCopyDeadline != 0 { 96 | imageCopyDeadline = cfg.ImageCopyDeadline 97 | } 98 | 99 | imagePullSecretProvider := setupImagePullSecretsProvider() 100 | 101 | // Inform secret provider about managed private source registries 102 | imagePullSecretProvider.SetAuthenticatedRegistries(sourceRegistryClients) 103 | 104 | wh, err := webhook.NewImageSwapperWebhookWithOpts( 105 | targetRegistryClient, 106 | webhook.Filters(cfg.Source.Filters), 107 | webhook.ImagePullSecretsProvider(imagePullSecretProvider), 108 | webhook.ImageSwapPolicy(imageSwapPolicy), 109 | webhook.ImageCopyPolicy(imageCopyPolicy), 110 | webhook.ImageCopyDeadline(imageCopyDeadline), 111 | ) 112 | if err != nil { 113 | log.Err(err).Msg("error creating webhook") 114 | os.Exit(1) 115 | } 116 | 117 | // Get the handler for our webhook. 118 | whHandler, err := kwhhttp.HandlerFor(kwhhttp.HandlerConfig{Webhook: wh}) 119 | if err != nil { 120 | log.Err(err).Msg("error creating webhook handler") 121 | os.Exit(1) 122 | } 123 | 124 | handler := http.NewServeMux() 125 | handler.Handle("/webhook", whHandler) 126 | handler.Handle("/metrics", promhttp.Handler()) 127 | handler.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 128 | _, err := w.Write([]byte(` 129 | k8s-image-webhook 130 | 131 |

k8s-image-webhook

132 | 133 | 134 | `)) 135 | 136 | if err != nil { 137 | log.Error() 138 | } 139 | }) 140 | 141 | srv := &http.Server{ 142 | Addr: cfg.ListenAddress, 143 | // Good practice to set timeouts to avoid Slowloris attacks. 144 | WriteTimeout: time.Second * 15, 145 | ReadTimeout: time.Second * 15, 146 | IdleTimeout: time.Second * 60, 147 | Handler: handler, 148 | } 149 | 150 | go func() { 151 | log.Info().Msgf("Listening on %v", cfg.ListenAddress) 152 | //err = http.ListenAndServeTLS(":8080", cfg.certFile, cfg.keyFile, whHandler) 153 | if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { 154 | if err := srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile); err != nil { 155 | log.Err(err).Msg("error serving webhook") 156 | os.Exit(1) 157 | } 158 | } else { 159 | if err := srv.ListenAndServe(); err != nil { 160 | log.Err(err).Msg("error serving webhook") 161 | os.Exit(1) 162 | } 163 | } 164 | }() 165 | 166 | c := make(chan os.Signal, 1) 167 | // We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) or SIGTERM 168 | // SIGKILL, SIGQUIT will not be caught. 169 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 170 | 171 | // Block until we receive our signal. 172 | <-c 173 | 174 | // Create a deadline to wait for. 175 | var wait time.Duration 176 | ctx, cancel := context.WithTimeout(context.Background(), wait) 177 | defer cancel() 178 | // Doesn't block if no connections, but will otherwise wait 179 | // until the timeout deadline. 180 | if err := srv.Shutdown(ctx); err != nil { 181 | log.Err(err).Msg("Error during shutdown") 182 | } 183 | // Optionally, you could run srv.Shutdown in a goroutine and block on 184 | // <-ctx.Done() if your application should wait for other services 185 | // to finalize based on context cancellation. 186 | log.Info().Msg("Shutting down") 187 | os.Exit(0) 188 | }, 189 | } 190 | 191 | // Execute adds all child commands to the root command and sets flags appropriately. 192 | // This is called by main.main(). It only needs to happen once to the rootCmd. 193 | func Execute() { 194 | if err := rootCmd.Execute(); err != nil { 195 | fmt.Println(err) 196 | os.Exit(1) 197 | } 198 | } 199 | 200 | func init() { 201 | cobra.OnInitialize(initConfig, initLogger) 202 | 203 | // Here you will define your flags and configuration settings. 204 | // Cobra supports persistent flags, which, if defined here, 205 | // will be global for your application. 206 | 207 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.k8s-image-swapper.yaml)") 208 | rootCmd.PersistentFlags().StringVar(&cfg.LogLevel, "log-level", "info", "Only log messages with the given severity or above. Valid levels: [debug, info, warn, error, fatal]") 209 | rootCmd.PersistentFlags().StringVar(&cfg.LogFormat, "log-format", "json", "Format of the log messages. Valid levels: [json, console]") 210 | 211 | // Cobra also supports local flags, which will only run 212 | // when this action is called directly. 213 | //rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 214 | rootCmd.Flags().StringVar(&cfg.ListenAddress, "listen-address", ":8443", "Address on which to expose the webhook") 215 | rootCmd.Flags().StringVar(&cfg.TLSCertFile, "tls-cert-file", "", "File containing the TLS certificate") 216 | rootCmd.Flags().StringVar(&cfg.TLSKeyFile, "tls-key-file", "", "File containing the TLS private key") 217 | rootCmd.Flags().BoolVar(&cfg.DryRun, "dry-run", true, "If true, print the action taken without taking it") 218 | } 219 | 220 | // initConfig reads in config file and ENV variables if set. 221 | func initConfig() { 222 | // Default to aws target registry type if none are defined 223 | config.SetViperDefaults(viper.GetViper()) 224 | 225 | if cfgFile != "" { 226 | // Use config file from the flag. 227 | viper.SetConfigFile(cfgFile) 228 | } else { 229 | // Find home directory. 230 | home, err := homedir.Dir() 231 | if err != nil { 232 | fmt.Println(err) 233 | os.Exit(1) 234 | } 235 | 236 | // Search config in home directory with name ".k8s-image-swapper" (without extension). 237 | viper.AddConfigPath(home) 238 | viper.AddConfigPath(".") 239 | viper.SetConfigType("yaml") 240 | viper.SetConfigName(".k8s-image-swapper") 241 | } 242 | 243 | viper.AutomaticEnv() // read in environment variables that match 244 | 245 | // If a config file is found, read it in. 246 | if err := viper.ReadInConfig(); err == nil { 247 | log.Info().Str("file", viper.ConfigFileUsed()).Msg("using config file") 248 | } 249 | 250 | if err := viper.Unmarshal(&cfg); err != nil { 251 | log.Err(err).Msg("failed to unmarshal the config file") 252 | } 253 | 254 | //validate := validator.New() 255 | //if err := validate.Struct(cfg); err != nil { 256 | // validationErrors := err.(validator.ValidationErrors) 257 | // log.Err(validationErrors).Msg("validation errors for config file") 258 | //} 259 | } 260 | 261 | // initLogger configures the log level 262 | func initLogger() { 263 | if cfg.LogFormat == "console" { 264 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 265 | } 266 | 267 | lvl, err := zerolog.ParseLevel(cfg.LogLevel) 268 | if err != nil { 269 | lvl = zerolog.InfoLevel 270 | log.Err(err).Msgf("Could not set log level to '%v'.", cfg.LogLevel) 271 | } 272 | 273 | zerolog.SetGlobalLevel(lvl) 274 | 275 | // add file and line number to log if level is trace 276 | if lvl == zerolog.TraceLevel { 277 | log.Logger = log.With().Caller().Logger() 278 | } 279 | } 280 | 281 | // setupImagePullSecretsProvider configures the provider handling secrets 282 | func setupImagePullSecretsProvider() secrets.ImagePullSecretsProvider { 283 | config, err := rest.InClusterConfig() 284 | if err != nil { 285 | log.Warn().Err(err).Msg("failed to configure Kubernetes client, will continue without reading secrets") 286 | return secrets.NewDummyImagePullSecretsProvider() 287 | } 288 | 289 | clientset, err := kubernetes.NewForConfig(config) 290 | if err != nil { 291 | log.Warn().Err(err).Msg("failed to configure Kubernetes client, will continue without reading secrets") 292 | return secrets.NewDummyImagePullSecretsProvider() 293 | } 294 | 295 | return secrets.NewKubernetesImagePullSecretsProvider(clientset) 296 | } 297 | -------------------------------------------------------------------------------- /deploy/k8s-image-swapper/README.md: -------------------------------------------------------------------------------- 1 | Relocated to [estahn/charts/k8s-image-swapper](https://github.com/estahn/charts/tree/main/charts/k8s-image-swapper) 2 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | The configuration is managed via the config file `.k8s-image-swapper.yaml`. 4 | Some options can be overridden via parameters, e.g. `--dry-run`. 5 | 6 | ## Dry Run 7 | 8 | The option `dryRun` allows to run the webhook without executing the actions, e.g. repository creation, 9 | image download and manifest mutation. 10 | 11 | !!! example 12 | ```yaml 13 | dryRun: true 14 | ``` 15 | 16 | ## Log Level & Format 17 | 18 | The option `logLevel` & `logFormat` allow to adjust the verbosity and format (e.g. `json`, `console`). 19 | 20 | !!! example 21 | ```yaml 22 | logLevel: debug 23 | logFormat: console 24 | ``` 25 | 26 | ## ImageSwapPolicy 27 | 28 | The option `imageSwapPolicy` (default: `exists`) defines the mutation strategy used. 29 | 30 | * `always`: Will always swap the image regardless of the image existence in the target registry. 31 | This can result in pods ending in state ImagePullBack if images fail to be copied to the target registry. 32 | * `exists`: Only swaps the image if it exits in the target registry. 33 | This can result in pods pulling images from the source registry, e.g. the first pod pulls 34 | from source registry, subsequent pods pull from target registry. 35 | 36 | ## ImageCopyPolicy 37 | 38 | The option `imageCopyPolicy` (default: `delayed`) defines the image copy strategy used. 39 | 40 | * `delayed`: Submits the copy job to a process queue and moves on. 41 | * `immediate`: Submits the copy job to a process queue and waits for it to finish (deadline defined by `imageCopyDeadline`). 42 | * `force`: Attempts to immediately copy the image (deadline defined by `imageCopyDeadline`). 43 | * `none`: Do not copy the image. 44 | 45 | ## ImageCopyDeadline 46 | 47 | The option `imageCopyDeadline` (default: `8s`) defines the duration after which the image copy if aborted. 48 | 49 | This option only applies for `immediate` and `force` image copy strategies. 50 | 51 | 52 | ## Source 53 | 54 | This section configures details about the image source. 55 | 56 | ### Registries 57 | 58 | The option `source.registries` describes a list of registries to pull images from, using a specific configuration. 59 | 60 | #### AWS 61 | 62 | By providing configuration on AWS registries you can ask `k8s-image-swapper` to handle the authentication using the same credentials as for the target AWS registry. 63 | This authentication method is the default way to get authorized by a private registry if the targeted Pod does not provide an `imagePullSecret`. 64 | 65 | Registries are described with an AWS account ID and region, mostly to construct the ECR domain `[ACCOUNT_ID].dkr.ecr.[REGION].amazonaws.com`. 66 | 67 | !!! example 68 | ```yaml 69 | source: 70 | registries: 71 | - type: aws 72 | aws: 73 | accountId: 123456789 74 | region: ap-southeast-2 75 | - type: aws 76 | aws: 77 | accountId: 234567890 78 | region: us-east-1 79 | ``` 80 | ### Filters 81 | 82 | Filters provide control over what pods will be processed. 83 | By default, all pods will be processed. 84 | If a condition matches, the pod will **NOT** be processed. 85 | 86 | [JMESPath](https://jmespath.org/) is used as query language and allows flexible rules for most use-cases. 87 | 88 | !!! info 89 | The data structure used for JMESPath is as follows: 90 | 91 | === "Structure" 92 | ```yaml 93 | obj: 94 | 95 | container: 96 | 97 | ``` 98 | 99 | === "Example" 100 | ```yaml 101 | obj: 102 | metadata: 103 | name: static-web 104 | labels: 105 | role: myrole 106 | spec: 107 | containers: 108 | - name: web 109 | image: nginx 110 | ports: 111 | - name: web 112 | containerPort: 80 113 | protocol: TCP 114 | container: 115 | name: web 116 | image: nginx 117 | ports: 118 | - name: web 119 | containerPort: 80 120 | protocol: TCP 121 | ``` 122 | 123 | Below you will find a list of common queries and/or ideas: 124 | 125 | !!! tip "List of common queries/ideas" 126 | * Do not process if namespace equals `kube-system` (_Helm chart default_) 127 | ```yaml 128 | source: 129 | filters: 130 | - jmespath: "obj.metadata.namespace == 'kube-system'" 131 | ``` 132 | * Only process if namespace equals `playground` 133 | ```yaml 134 | source: 135 | filters: 136 | - jmespath: "obj.metadata.namespace != 'playground'" 137 | ``` 138 | * Only process if namespace ends with `-dev` 139 | ```yaml 140 | source: 141 | filters: 142 | - jmespath: "ends_with(obj.metadata.namespace,'-dev')" 143 | ``` 144 | * Do not process AWS ECR images 145 | ```yaml 146 | source: 147 | filters: 148 | - jmespath: "contains(container.image, '.dkr.ecr.') && contains(container.image, '.amazonaws.com')" 149 | ``` 150 | 151 | `k8s-image-swapper` will log the filter data and result in `debug` mode. 152 | This can be used in conjunction with [JMESPath.org](https://jmespath.org/) which 153 | has a live editor that can be used as a playground to experiment with more complex queries. 154 | 155 | ## Target 156 | 157 | This section configures details about the image target. 158 | The option `target` allows to specify which type of registry you set as your target (AWS, GCP...). 159 | At the moment, `aws` and `gcp` are the only supported values. 160 | 161 | ### AWS 162 | 163 | The option `target.aws` holds details about the target registry storing the images. 164 | The AWS Account ID and Region is primarily used to construct the ECR domain `[ACCOUNTID].dkr.ecr.[REGION].amazonaws.com`. 165 | 166 | !!! example 167 | ```yaml 168 | target: 169 | type: aws 170 | aws: 171 | accountId: 123456789 172 | region: ap-southeast-2 173 | ``` 174 | 175 | #### ECR Options 176 | 177 | ##### Tags 178 | 179 | This provides a way to add custom tags to newly created repositories. This may be useful while looking at AWS costs. 180 | It's a slice of `Key` and `Value`. 181 | 182 | !!! example 183 | ```yaml 184 | target: 185 | type: aws 186 | aws: 187 | ecrOptions: 188 | tags: 189 | - key: cluster 190 | value: myCluster 191 | ``` 192 | 193 | ### GCP 194 | 195 | The option `target.gcp` holds details about the target registry storing the images. 196 | The GCP location, projectId, and repositoryId are used to constrct the GCP Artifact Registry domain `[LOCATION]-docker.pkg.dev/[PROJECT_ID]/[REPOSITORY_ID]`. 197 | 198 | !!! example 199 | ```yaml 200 | target: 201 | type: gcp 202 | gcp: 203 | location: us-central1 204 | projectId: gcp-project-123 205 | repositoryId: main 206 | ``` 207 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ### Is pulling from private registries supported? 4 | 5 | Yes, `imagePullSecrets` on `Pod` and `ServiceAccount` level in the hooked pod definition are supported. 6 | 7 | It is also possible to provide a list of ECRs to which authentication is handled by `k8s-image-swapper` using the same credentials as for the target registry. Please see [Configuration > Source - AWS](configuration.md#Private-registries). 8 | 9 | ### Are config changes reloaded gracefully? 10 | 11 | Not yet, they require a pod rotation. 12 | 13 | ### What happens if the image is not found in the target registry? 14 | 15 | Please see [Configuration > ImageCopyPolicy](configuration.md#imagecopypolicy). 16 | 17 | ### What level of registry outage does this handle? 18 | 19 | If the source image registry is not reachable it will replace the reference with the target registry reference. 20 | If the target registry is down it will do the same. It has no notion of the target registry being up or down. 21 | 22 | ### What happens if `k8s-image-swapper` is unavailable? 23 | 24 | Kubernetes will continue to work as if `k8s-image-swapper` was not installed. 25 | The webhook [failure policy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#failure-policy) 26 | is set to `Ignore`. 27 | 28 | !!! tip 29 | Environments with strict compliance requirements (or air-gapped) may overwrite this with `Fail` to 30 | avoid falling back to the public images. 31 | 32 | ### Why are sidecar images not being replaced? 33 | 34 | A Kubernetes cluster can have multiple mutating webhooks. 35 | Mutating webhooks execute sequentiatlly and each can change a submitted object. 36 | Changes may be applied after `k8s-image-swapper` was executed, e.g. Istio injecting a sidecar. 37 | 38 | ``` 39 | ... -> k8s-image-swapper -> Istio sidecar injection --> ... 40 | ``` 41 | 42 | Kubernetes 1.15+ allows to re-run webhooks if a mutating webhook modifies an object. 43 | The behaviour is controlled by the [Reinvocation policy](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#reinvocation-policy). 44 | 45 | > reinvocationPolicy may be set to `Never` or `IfNeeded`. It defaults to Never. 46 | > 47 | > * `Never`: the webhook must not be called more than once in a single admission evaluation 48 | > * `IfNeeded`: the webhook may be called again as part of the admission evaluation if the object being admitted is modified by other admission plugins after the initial webhook call. 49 | 50 | The reinvocation policy can be set in the helm chart as follows: 51 | 52 | !!! example "Helm Chart" 53 | ```yaml 54 | webhook: 55 | reinvocationPolicy: IfNeeded 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | This document will provide guidance for installing `k8s-image-swapper`. 4 | 5 | ## Prerequisites 6 | 7 | `k8s-image-swapper` will automatically create image repositories and mirror images into them. 8 | This requires certain permissions for your target registry (_only AWS ECR and GCP ArtifactRegistry are supported atm_). 9 | 10 | Before you get started choose a namespace to install `k8s-image-swapper` in, e.g. `operations` or `k8s-image-swapper`. 11 | Ensure the namespace exists and is configured as your current context[^1]. 12 | All examples below will omit the namespace. 13 | 14 | ### AWS ECR as a target registry 15 | 16 | AWS supports a variety of authentication strategies. 17 | `k8s-image-swapper` uses the official Amazon AWS SDK and therefore supports [all available authentication strategies](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html). 18 | Choose from one of the strategies below or an alternative if needed. 19 | 20 | #### IAM credentials 21 | 22 | 1. Create an IAM user (e.g. `k8s-image-swapper`) with permissions[^2] to create ECR repositories and upload container images. 23 | An IAM policy example can be found in the footnotes[^2]. 24 | 2. Create a Kubernetes secret (e.g. `k8s-image-swapper-aws`) containing the IAM credentials you just obtained, e.g. 25 | 26 | ```bash 27 | kubectl create secret generic k8s-image-swapper-aws \ 28 | --from-literal=aws_access_key_id=<...> \ 29 | --from-literal=aws_secret_access_key=<...> 30 | ``` 31 | 32 | #### Using ECR registries cross-account 33 | 34 | Although ECR allows creating registry policy that allows reposistories creation from different account, there's no way to push anything to these repositories. 35 | ECR resource-level policy can not be applied during creation, and to apply it afterwards we need ecr:SetRepositoryPolicy permission, which foreign account doesn't have. 36 | 37 | One way out of this conundrum is to assume the role in target account 38 | 39 | ```yaml title=".k8s-image-swapper.yml" 40 | target: 41 | type: aws 42 | aws: 43 | accountId: 123456789 44 | region: ap-southeast-2 45 | role: arn:aws:iam::123456789012:role/roleName 46 | ``` 47 | !!! note 48 | Make sure that target role has proper trust permissions that allow to assume it cross-account 49 | 50 | !!! note 51 | In order te be able to pull images from outside accounts, you will have to apply proper access policy 52 | 53 | 54 | #### Access policy 55 | 56 | You can specify the access policy that will be applied to the created repos in config. Policy should be raw json string. 57 | For example: 58 | ```yaml title=".k8s-image-swapper.yml" 59 | target: 60 | type: aws 61 | aws: 62 | accountId: 123456789 63 | region: ap-southeast-2 64 | role: arn:aws:iam::123456789012:role/roleName 65 | ecrOptions: 66 | accessPolicy: | 67 | { 68 | "Statement": [ 69 | { 70 | "Sid": "AllowCrossAccountPull", 71 | "Effect": "Allow", 72 | "Principal": { 73 | "AWS": "*" 74 | }, 75 | "Action": [ 76 | "ecr:GetDownloadUrlForLayer", 77 | "ecr:BatchGetImage", 78 | "ecr:BatchCheckLayerAvailability" 79 | ], 80 | "Condition": { 81 | "StringEquals": { 82 | "aws:PrincipalOrgID": "o-xxxxxxxxxx" 83 | } 84 | } 85 | } 86 | ], 87 | "Version": "2008-10-17" 88 | } 89 | ``` 90 | 91 | #### Lifecycle policy 92 | 93 | Similarly to access policy, lifecycle policy can be specified, for example: 94 | 95 | ```yaml title=".k8s-image-swapper.yml" 96 | target: 97 | type: aws 98 | aws: 99 | accountId: 123456789 100 | region: ap-southeast-2 101 | role: arn:aws:iam::123456789012:role/roleName 102 | ecrOptions: 103 | lifecyclePolicy: | 104 | { 105 | "rules": [ 106 | { 107 | "rulePriority": 1, 108 | "description": "Rule 1", 109 | "selection": { 110 | "tagStatus": "any", 111 | "countType": "imageCountMoreThan", 112 | "countNumber": 1000 113 | }, 114 | "action": { 115 | "type": "expire" 116 | } 117 | } 118 | ] 119 | } 120 | ``` 121 | 122 | #### Service Account 123 | 124 | 1. Create an Webidentity IAM role (e.g. `k8s-image-swapper`) with the following trust policy, e.g 125 | ```json 126 | { 127 | "Version": "2012-10-17", 128 | "Statement": [ 129 | { 130 | "Effect": "Allow", 131 | "Principal": { 132 | "Federated": "arn:aws:iam::${your_aws_account_id}:oidc-provider/${oidc_image_swapper_role_arn}" 133 | }, 134 | "Action": "sts:AssumeRoleWithWebIdentity", 135 | "Condition": { 136 | "StringEquals": { 137 | "${oidc_image_swapper_role_arn}:sub": "system:serviceaccount:${k8s_image_swapper_namespace}:${k8s_image_swapper_serviceaccount_name}" 138 | } 139 | } 140 | } 141 | ] 142 | } 143 | ``` 144 | 145 | 2. Create and attach permission policy[^2] to the role from Step 1.. 146 | 147 | Note: You can see a complete example below in [Terraform](Terraform) 148 | 149 | ### GCP Artifact Registry as a target registry 150 | 151 | To target a GCP Artifact Registry set the `target.type` to `gcp` and provide additional metadata in the configuration. 152 | 153 | ```yaml title=".k8s-image-swapper.yml" 154 | target: 155 | type: gcp 156 | gcp: 157 | location: us-central1 158 | projectId: gcp-project-123 159 | repositoryId: main 160 | ``` 161 | 162 | !!! note 163 | This is fundamentally different from the AWS ECR implementation since all images will be stored under *one* GCP Artifact Registry repository. 164 |
165 | ![GCP Console - Artifact Registry](img/gcp_artifact_registry.png){ loading=lazy } 166 |
167 | 168 | #### Create Repository 169 | 170 | Create and configure a single GCP Artifact Registry repository to store Docker images for `k8s-image-swapper`. 171 | 172 | === "Terraform" 173 | 174 | ```terraform 175 | resource "google_artifact_registry_repository" "repo" { 176 | project = var.project_id 177 | location = var.region 178 | repository_id = "main" 179 | description = "main docker repository" 180 | format = "DOCKER" 181 | } 182 | ``` 183 | 184 | #### IAM for GKE / Nodes / Compute 185 | 186 | Give the compute service account that the nodes use, permissions to pull images from Artifact Registry. 187 | 188 | === "Terraform" 189 | 190 | ```terraform 191 | resource "google_project_iam_member" "compute_artifactregistry_reader" { 192 | project = var.project_id 193 | role = "roles/artifactregistry.reader" 194 | member = "serviceAccount:${var.compute_sa_email}" 195 | } 196 | ``` 197 | 198 | Allow GKE node pools to access Artifact Registry API via OAuth scope `https://www.googleapis.com/auth/devstorage.read_only` 199 | 200 | === "Terraform" 201 | 202 | ```terraform 203 | resource "google_container_node_pool" "primary_nodes_v1" { 204 | project = var.project_id 205 | name = "${google_container_cluster.primary.name}-node-pool-v1" 206 | location = var.region 207 | cluster = google_container_cluster.primary.name 208 | ... 209 | node_config { 210 | oauth_scopes = [ 211 | ... 212 | "https://www.googleapis.com/auth/devstorage.read_only", 213 | ] 214 | ... 215 | } 216 | ... 217 | } 218 | ``` 219 | 220 | #### IAM for `k8s-image-swapper` 221 | 222 | On GKE, leverage Workload Identity for the `k8s-image-swapper` K8s service account. 223 | 224 | 1. Enable Workload Identity on the GKE cluster[^3]. 225 | 226 | === "Terraform" 227 | 228 | ```terraform 229 | resource "google_container_cluster" "primary" { 230 | ... 231 | workload_identity_config { 232 | workload_pool = "${var.project_id}.svc.id.goog" 233 | } 234 | ... 235 | } 236 | ``` 237 | 238 | 2. Setup a Google Service Account (GSA) for `k8s-image-swapper`. 239 | 240 | === "Terraform" 241 | 242 | ```terraform 243 | resource "google_service_account" "k8s_image_swapper_service_account" { 244 | project = var.project_id 245 | account_id = k8s-image-swapper 246 | display_name = "Workload identity for kube-system/k8s-image-swapper" 247 | } 248 | ``` 249 | 250 | 3. Setup Workload Identity for the GSA 251 | 252 | !!! note 253 | This example assumes `k8s-image-swapper` is deployed to the `kube-system` namespace and uses `k8s-image-swapper` as the K8s service account name. 254 | 255 | === "Terraform" 256 | 257 | ```terraform 258 | resource "google_service_account_iam_member" "k8s_image_swapper_workload_identity_binding" { 259 | service_account_id = google_service_account.k8s_image_swapper_service_account.name 260 | role = "roles/iam.workloadIdentityUser" 261 | member = "serviceAccount:${var.project_id}.svc.id.goog[kube-system/k8s-image-swapper]" 262 | 263 | depends_on = [ 264 | google_container_cluster.primary, 265 | ] 266 | } 267 | ``` 268 | 269 | 4. Bind permissions for GSA to access Artifact Registry 270 | 271 | Setup the `roles/artifactregistry.writer` role in order for `k8s-image-swapper` to be able to read/write images to the Artifact Repository. 272 | 273 | === "Terraform" 274 | 275 | ```terraform 276 | resource "google_project_iam_member" "k8s_image_swapper_service_account_binding" { 277 | project = var.project_id 278 | role = "roles/artifactregistry.writer" 279 | member = "serviceAccount:${google_service_account.k8s_image_swapper_service_account.email}" 280 | } 281 | ``` 282 | 283 | 5. (Optional) Bind additional permissions for GSA to read from other GCP Artifact Registries 284 | 6. Set Workload Identity annotation on `k8s-iamge-swapper` service account 285 | ```yaml 286 | serviceAccount: 287 | annotations: 288 | iam.gke.io/gcp-service-account: k8s-image-swapper@gcp-project-123.iam.gserviceaccount.com 289 | ``` 290 | 291 | #### Firewall 292 | 293 | If running `k8s-image-swapper` on a private GKE cluster you must have a firewall rule enabled to allow the GKE control plane to talk to `k8s-image-swapper` on port `8443`. See the following Terraform example for the firewall configuration. 294 | 295 | === "Terraform" 296 | 297 | ```terraform 298 | resource "google_compute_firewall" "k8s_image_swapper_webhook" { 299 | project = var.project_id 300 | name = "gke-${google_container_cluster.primary.name}-k8s-image-swapper-webhook" 301 | network = google_compute_network.vpc.name 302 | direction = "INGRESS" 303 | source_ranges = [google_container_cluster.primary.private_cluster_config[0].master_ipv4_cidr_block] 304 | target_tags = [google_container_cluster.primary.name] 305 | 306 | allow { 307 | ports = ["8443"] 308 | protocol = "tcp" 309 | } 310 | } 311 | ``` 312 | 313 | For more details see https://cloud.google.com/kubernetes-engine/docs/how-to/private-clusters#add_firewall_rules 314 | 315 | ## Helm 316 | 317 | 1. Add the Helm chart repository: 318 | ```bash 319 | helm repo add estahn https://estahn.github.io/charts/ 320 | ``` 321 | 2. Update the local chart information: 322 | ```bash 323 | helm repo update 324 | ``` 325 | 3. Install `k8s-image-swapper` 326 | ``` 327 | helm install k8s-image-swapper estahn/k8s-image-swapper \ 328 | --set config.target.aws.accountId=$AWS_ACCOUNT_ID \ 329 | --set config.target.aws.region=$AWS_DEFAULT_REGION \ 330 | --set awsSecretName=k8s-image-swapper-aws 331 | ``` 332 | 333 | !!! note 334 | `awsSecretName` is not required for the Service Account method and instead the service account is annotated: 335 | ```yaml 336 | serviceAccount: 337 | create: true 338 | annotations: 339 | eks.amazonaws.com/role-arn: ${oidc_image_swapper_role_arn} 340 | ``` 341 | 342 | ## Terraform 343 | 344 | Full example of helm chart deployment with AWS service account setup in Terraform. 345 | 346 | 347 | ```terraform 348 | data "aws_caller_identity" "current" { 349 | } 350 | 351 | variable "cluster_oidc_provider" { 352 | default = "oidc.eks.ap-southeast-1.amazonaws.com/id/ABCDEFGHIJKLMNOPQRSTUVWXYZ012345" 353 | description = "example oidc endpoint that is created during eks deployment" 354 | } 355 | 356 | variable "cluster_name" { 357 | default = "test" 358 | description = "name of the eks cluster being deployed to" 359 | } 360 | 361 | 362 | variable "region" { 363 | default = "ap-southeast-1" 364 | description = "name of the eks cluster being deployed to" 365 | } 366 | 367 | variable "k8s_image_swapper_namespace" { 368 | default = "kube-system" 369 | description = "namespace to install k8s-image-swapper" 370 | } 371 | 372 | variable "k8s_image_swapper_name" { 373 | default = "k8s-image-swapper" 374 | description = "name for k8s-image-swapper release and service account" 375 | } 376 | 377 | #k8s-image-swapper helm chart 378 | resource "helm_release" "k8s_image_swapper" { 379 | name = var.k8s_image_swapper_name 380 | namespace = "kube-system" 381 | repository = "https://estahn.github.io/charts/" 382 | chart = "k8s-image-swapper" 383 | keyring = "" 384 | version = "1.0.1" 385 | values = [ 386 | < `docker.io/library/nginx:latest`. 532 | 533 | [^3]: [Google Kubernetes Engine (GKE) > Documentation > Guides > Use Workload Identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) 534 | [^4]: [Google Kubernetes Engine (GKE) > Documentation > Guides > Creating a private cluster > Adding firewall rules for specific use cases](https://cloud.google.com/kubernetes-engine/docs/how-to/private-clusters#add_firewall_rules) 535 | -------------------------------------------------------------------------------- /docs/img/gcp_artifact_registry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/estahn/k8s-image-swapper/01341483250a885b96c01e7fb713be7f0b0452f5/docs/img/gcp_artifact_registry.png -------------------------------------------------------------------------------- /docs/img/indiana.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/estahn/k8s-image-swapper/01341483250a885b96c01e7fb713be7f0b0452f5/docs/img/indiana.gif -------------------------------------------------------------------------------- /docs/img/k8s-image-swapper_explainer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/estahn/k8s-image-swapper/01341483250a885b96c01e7fb713be7f0b0452f5/docs/img/k8s-image-swapper_explainer.gif -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | Raiders of the Lost Ark 3 |

k8s-image-swapper

4 |

Mirror images into your own registry and swap image references automatically.

5 |

6 | 7 | `k8s-image-swapper` is a mutating webhook for Kubernetes, downloading images into your own registry and pointing the images to that new location. 8 | It is an alternative to a [docker pull-through proxy](https://docs.docker.com/registry/recipes/mirror/). 9 | The feature set was primarily designed with Amazon ECR in mind but may work with other registries. 10 | 11 | ## Benefits 12 | 13 | Using `k8s-image-swapper` will improve the overall availability, reliability, durability and resiliency of your 14 | Kubernetes cluster by keeping 3rd-party images mirrored into your own registry. 15 | 16 | `k8s-image-swapper` will transparently consolidate all images into a single registry without the need to adjust manifests 17 | therefore reducing the impact of external registry failures, rate limiting, network issues, change or removal of images 18 | while reducing data traffic and therefore cost. 19 | 20 | **TL;DR:** 21 | 22 | * Protect against: 23 | * external registry failure ([quay.io outage](https://www.reddit.com/r/devops/comments/f9kiej/quayio_is_experiencing_an_outage/)) 24 | * image pull rate limiting ([docker.io rate limits](https://www.docker.com/blog/scaling-docker-to-serve-millions-more-developers-network-egress/)) 25 | * accidental image changes 26 | * removal of images 27 | * Use in air-gaped environments without the need to change manifests 28 | * Reduce NAT ingress traffic/cost 29 | 30 | ## How it works 31 | 32 | ![Explainer](img/k8s-image-swapper_explainer.gif) 33 | -------------------------------------------------------------------------------- /docs/overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block outdated %} 4 | You're not viewing the latest version. 5 | 6 | Click here to go to latest. 7 | 8 | {% endblock %} 9 | 10 | {% block extrahead %} 11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/estahn/k8s-image-swapper 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | cloud.google.com/go/artifactregistry v1.17.1 7 | github.com/alitto/pond v1.9.2 8 | github.com/aws/aws-sdk-go v1.55.7 9 | github.com/containers/image/v5 v5.35.0 10 | github.com/dgraph-io/ristretto v0.2.0 11 | github.com/evanphx/json-patch v5.9.11+incompatible 12 | github.com/go-co-op/gocron v1.37.0 13 | github.com/gruntwork-io/terratest v0.49.0 14 | github.com/jmespath/go-jmespath v0.4.0 15 | github.com/mitchellh/go-homedir v1.1.0 16 | github.com/prometheus/client_golang v1.22.0 17 | github.com/rs/zerolog v1.34.0 18 | github.com/slok/kubewebhook/v2 v2.5.0 19 | github.com/spf13/cobra v1.9.1 20 | github.com/spf13/viper v1.20.1 21 | github.com/stretchr/testify v1.10.0 22 | google.golang.org/api v0.234.0 23 | gopkg.in/yaml.v2 v2.4.0 // indirect 24 | k8s.io/api v0.33.1 25 | k8s.io/apimachinery v0.33.1 26 | k8s.io/client-go v0.33.1 27 | sigs.k8s.io/yaml v1.4.0 // indirect 28 | ) 29 | 30 | require ( 31 | cloud.google.com/go v0.120.0 // indirect 32 | cloud.google.com/go/auth v0.16.1 // indirect 33 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 34 | cloud.google.com/go/compute/metadata v0.7.0 // indirect 35 | cloud.google.com/go/iam v1.5.2 // indirect 36 | cloud.google.com/go/longrunning v0.6.7 // indirect 37 | dario.cat/mergo v1.0.1 // indirect 38 | filippo.io/edwards25519 v1.1.0 // indirect 39 | github.com/BurntSushi/toml v1.5.0 // indirect 40 | github.com/Microsoft/go-winio v0.6.2 // indirect 41 | github.com/Microsoft/hcsshim v0.12.9 // indirect 42 | github.com/agext/levenshtein v1.2.3 // indirect 43 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 44 | github.com/aws/aws-sdk-go-v2 v1.32.5 // indirect 45 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect 46 | github.com/aws/aws-sdk-go-v2/config v1.28.5 // indirect 47 | github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect 48 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect 49 | github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.41 // indirect 50 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 // indirect 51 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 // indirect 52 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect 53 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.24 // indirect 54 | github.com/aws/aws-sdk-go-v2/service/acm v1.30.6 // indirect 55 | github.com/aws/aws-sdk-go-v2/service/autoscaling v1.51.0 // indirect 56 | github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.44.0 // indirect 57 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.37.1 // indirect 58 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0 // indirect 59 | github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6 // indirect 60 | github.com/aws/aws-sdk-go-v2/service/ecs v1.52.0 // indirect 61 | github.com/aws/aws-sdk-go-v2/service/iam v1.38.1 // indirect 62 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect 63 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.5 // indirect 64 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.5 // indirect 65 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 // indirect 66 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.5 // indirect 67 | github.com/aws/aws-sdk-go-v2/service/kms v1.37.6 // indirect 68 | github.com/aws/aws-sdk-go-v2/service/lambda v1.69.0 // indirect 69 | github.com/aws/aws-sdk-go-v2/service/rds v1.91.0 // indirect 70 | github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2 // indirect 71 | github.com/aws/aws-sdk-go-v2/service/s3 v1.69.0 // indirect 72 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6 // indirect 73 | github.com/aws/aws-sdk-go-v2/service/sns v1.33.6 // indirect 74 | github.com/aws/aws-sdk-go-v2/service/sqs v1.37.1 // indirect 75 | github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0 // indirect 76 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect 77 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect 78 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 // indirect 79 | github.com/aws/smithy-go v1.22.1 // indirect 80 | github.com/beorn7/perks v1.0.1 // indirect 81 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect 82 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect 83 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 84 | github.com/containerd/cgroups/v3 v3.0.5 // indirect 85 | github.com/containerd/errdefs v1.0.0 // indirect 86 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 87 | github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect 88 | github.com/containerd/typeurl/v2 v2.2.3 // indirect 89 | github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect 90 | github.com/containers/ocicrypt v1.2.1 // indirect 91 | github.com/containers/storage v1.58.0 // indirect 92 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 93 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 94 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 95 | github.com/distribution/reference v0.6.0 // indirect 96 | github.com/docker/distribution v2.8.3+incompatible // indirect 97 | github.com/docker/docker v28.0.4+incompatible // indirect 98 | github.com/docker/docker-credential-helpers v0.9.3 // indirect 99 | github.com/docker/go-connections v0.5.0 // indirect 100 | github.com/docker/go-units v0.5.0 // indirect 101 | github.com/dustin/go-humanize v1.0.1 // indirect 102 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 103 | github.com/felixge/httpsnoop v1.0.4 // indirect 104 | github.com/fsnotify/fsnotify v1.8.0 // indirect 105 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 106 | github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0 // indirect 107 | github.com/go-logr/logr v1.4.2 // indirect 108 | github.com/go-logr/stdr v1.2.2 // indirect 109 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 110 | github.com/go-openapi/jsonreference v0.21.0 // indirect 111 | github.com/go-openapi/swag v0.23.1 // indirect 112 | github.com/go-sql-driver/mysql v1.8.1 // indirect 113 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 114 | github.com/gogo/protobuf v1.3.2 // indirect 115 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 116 | github.com/gonvenience/bunt v1.3.5 // indirect 117 | github.com/gonvenience/neat v1.3.12 // indirect 118 | github.com/gonvenience/term v1.0.2 // indirect 119 | github.com/gonvenience/text v1.0.7 // indirect 120 | github.com/gonvenience/wrap v1.1.2 // indirect 121 | github.com/gonvenience/ytbx v1.4.4 // indirect 122 | github.com/google/gnostic-models v0.6.9 // indirect 123 | github.com/google/go-cmp v0.7.0 // indirect 124 | github.com/google/go-containerregistry v0.20.3 // indirect 125 | github.com/google/go-intervals v0.0.2 // indirect 126 | github.com/google/s2a-go v0.1.9 // indirect 127 | github.com/google/uuid v1.6.0 // indirect 128 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 129 | github.com/googleapis/gax-go/v2 v2.14.2 // indirect 130 | github.com/gorilla/mux v1.8.1 // indirect 131 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 132 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect 133 | github.com/gruntwork-io/go-commons v0.8.0 // indirect 134 | github.com/hashicorp/errwrap v1.1.0 // indirect 135 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 136 | github.com/hashicorp/go-getter/v2 v2.2.3 // indirect 137 | github.com/hashicorp/go-multierror v1.1.1 // indirect 138 | github.com/hashicorp/go-safetemp v1.0.0 // indirect 139 | github.com/hashicorp/go-version v1.7.0 // indirect 140 | github.com/hashicorp/hcl/v2 v2.22.0 // indirect 141 | github.com/hashicorp/terraform-json v0.23.0 // indirect 142 | github.com/homeport/dyff v1.6.0 // indirect 143 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 144 | github.com/jackc/pgpassfile v1.0.0 // indirect 145 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 146 | github.com/jackc/pgx/v5 v5.7.1 // indirect 147 | github.com/jackc/puddle/v2 v2.2.2 // indirect 148 | github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a // indirect 149 | github.com/josharian/intern v1.0.0 // indirect 150 | github.com/json-iterator/go v1.1.12 // indirect 151 | github.com/klauspost/compress v1.18.0 // indirect 152 | github.com/klauspost/pgzip v1.2.6 // indirect 153 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 154 | github.com/mailru/easyjson v0.9.0 // indirect 155 | github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 // indirect 156 | github.com/mattn/go-colorable v0.1.13 // indirect 157 | github.com/mattn/go-isatty v0.0.19 // indirect 158 | github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 // indirect 159 | github.com/mistifyio/go-zfs/v3 v3.0.1 // indirect 160 | github.com/mitchellh/go-ps v1.0.0 // indirect 161 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 162 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 163 | github.com/mitchellh/hashstructure v1.1.0 // indirect 164 | github.com/moby/docker-image-spec v1.3.1 // indirect 165 | github.com/moby/spdystream v0.5.0 // indirect 166 | github.com/moby/sys/capability v0.4.0 // indirect 167 | github.com/moby/sys/mountinfo v0.7.2 // indirect 168 | github.com/moby/sys/user v0.4.0 // indirect 169 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 170 | github.com/modern-go/reflect2 v1.0.2 // indirect 171 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 172 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 173 | github.com/opencontainers/go-digest v1.0.0 // indirect 174 | github.com/opencontainers/image-spec v1.1.1 // indirect 175 | github.com/opencontainers/runtime-spec v1.2.1 // indirect 176 | github.com/opencontainers/selinux v1.12.0 // indirect 177 | github.com/ostreedev/ostree-go v0.0.0-20210805093236-719684c64e4f // indirect 178 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 179 | github.com/pkg/errors v0.9.1 // indirect 180 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 181 | github.com/pquerna/otp v1.4.0 // indirect 182 | github.com/prometheus/client_model v0.6.1 // indirect 183 | github.com/prometheus/common v0.62.0 // indirect 184 | github.com/prometheus/procfs v0.15.1 // indirect 185 | github.com/robfig/cron/v3 v3.0.1 // indirect 186 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 187 | github.com/sagikazarmark/locafero v0.7.0 // indirect 188 | github.com/sergi/go-diff v1.3.1 // indirect 189 | github.com/sirupsen/logrus v1.9.3 // indirect 190 | github.com/sourcegraph/conc v0.3.0 // indirect 191 | github.com/spf13/afero v1.12.0 // indirect 192 | github.com/spf13/cast v1.7.1 // indirect 193 | github.com/spf13/pflag v1.0.6 // indirect 194 | github.com/stretchr/objx v0.5.2 // indirect 195 | github.com/subosito/gotenv v1.6.0 // indirect 196 | github.com/sylabs/sif/v2 v2.21.1 // indirect 197 | github.com/tchap/go-patricia/v2 v2.3.2 // indirect 198 | github.com/texttheater/golang-levenshtein v1.0.1 // indirect 199 | github.com/tmccombs/hcl2json v0.6.4 // indirect 200 | github.com/ulikunitz/xz v0.5.12 // indirect 201 | github.com/urfave/cli v1.22.16 // indirect 202 | github.com/vbatts/tar-split v0.12.1 // indirect 203 | github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 // indirect 204 | github.com/x448/float16 v0.8.4 // indirect 205 | github.com/zclconf/go-cty v1.15.0 // indirect 206 | go.opencensus.io v0.24.0 // indirect 207 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 208 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect 209 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 210 | go.opentelemetry.io/otel v1.35.0 // indirect 211 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 212 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 213 | go.uber.org/atomic v1.9.0 // indirect 214 | go.uber.org/multierr v1.9.0 // indirect 215 | golang.org/x/crypto v0.38.0 // indirect 216 | golang.org/x/mod v0.23.0 // indirect 217 | golang.org/x/net v0.40.0 // indirect 218 | golang.org/x/oauth2 v0.30.0 // indirect 219 | golang.org/x/sync v0.14.0 // indirect 220 | golang.org/x/sys v0.33.0 // indirect 221 | golang.org/x/term v0.32.0 // indirect 222 | golang.org/x/text v0.25.0 // indirect 223 | golang.org/x/time v0.11.0 // indirect 224 | golang.org/x/tools v0.29.0 // indirect 225 | gomodules.xyz/jsonpatch/v3 v3.0.1 // indirect 226 | gomodules.xyz/orderedmap v0.1.0 // indirect 227 | google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect 228 | google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect 229 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9 // indirect 230 | google.golang.org/grpc v1.72.1 // indirect 231 | google.golang.org/protobuf v1.36.6 // indirect 232 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 233 | gopkg.in/inf.v0 v0.9.1 // indirect 234 | gopkg.in/yaml.v3 v3.0.1 // indirect 235 | k8s.io/klog/v2 v2.130.1 // indirect 236 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 237 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 238 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 239 | sigs.k8s.io/randfill v1.0.0 // indirect 240 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 241 | ) 242 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 Enrico Stahn 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package main 23 | 24 | import "github.com/estahn/k8s-image-swapper/cmd" 25 | 26 | func main() { 27 | cmd.Execute() 28 | } 29 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Project information 2 | site_name: k8s-image-swapper 3 | site_url: https://estahn.github.io/k8s-image-swapper/ 4 | site_author: Enrico Stahn 5 | site_description: >- 6 | Mirror images into your own registry and swap image references automatically. 7 | 8 | # Repository 9 | repo_name: estahn/k8s-image-swapper 10 | repo_url: https://github.com/estahn/k8s-image-swapper 11 | edit_uri: "blob/main/docs/" 12 | 13 | # Copyright 14 | copyright: Copyright © 2020 Enrico Stahn 15 | 16 | use_directory_urls: false 17 | 18 | theme: 19 | name: material 20 | custom_dir: docs/overrides 21 | 22 | palette: 23 | 24 | # Palette toggle for automatic mode 25 | - media: "(prefers-color-scheme)" 26 | toggle: 27 | icon: material/brightness-auto 28 | name: Switch to light mode 29 | 30 | # Palette toggle for light mode 31 | - media: "(prefers-color-scheme: light)" 32 | scheme: default 33 | toggle: 34 | icon: material/brightness-7 35 | name: Switch to dark mode 36 | 37 | # Palette toggle for dark mode 38 | - media: "(prefers-color-scheme: dark)" 39 | scheme: slate 40 | toggle: 41 | icon: material/brightness-4 42 | name: Switch to system preference 43 | 44 | 45 | 46 | # Don't include MkDocs' JavaScript 47 | include_search_page: false 48 | search_index_only: true 49 | 50 | # Default values, taken from mkdocs_theme.yml 51 | language: en 52 | 53 | features: 54 | - tabs 55 | - content.action.edit 56 | - content.code.copy 57 | - navigation.footer 58 | 59 | # Plugins 60 | plugins: 61 | - search 62 | - minify: 63 | minify_html: true 64 | - markdownextradata: {} 65 | - social 66 | 67 | # Extensions 68 | markdown_extensions: 69 | - admonition 70 | - attr_list 71 | - md_in_html 72 | - codehilite: 73 | guess_lang: false 74 | - def_list 75 | - footnotes 76 | - meta 77 | - toc: 78 | permalink: true 79 | - pymdownx.arithmatex 80 | - pymdownx.betterem: 81 | smart_enable: all 82 | - pymdownx.caret 83 | - pymdownx.critic 84 | - pymdownx.details 85 | - pymdownx.emoji 86 | - pymdownx.highlight: 87 | use_pygments: true 88 | linenums_style: pymdownx-inline 89 | anchor_linenums: true 90 | - pymdownx.inlinehilite 91 | - pymdownx.keys 92 | - pymdownx.magiclink: 93 | repo_url_shorthand: true 94 | user: squidfunk 95 | repo: mkdocs-material 96 | - pymdownx.mark 97 | - pymdownx.smartsymbols 98 | - pymdownx.snippets: 99 | check_paths: true 100 | - pymdownx.superfences 101 | - pymdownx.tabbed: 102 | alternate_style: true 103 | - pymdownx.tasklist: 104 | custom_checkbox: true 105 | - pymdownx.tilde 106 | 107 | nav: 108 | - Home: index.md 109 | - Getting started: getting-started.md 110 | - Configuration: configuration.md 111 | - FAQ: faq.md 112 | # - Releases: 113 | # - 1.3.0: releases/1.3.0-NOTES.md 114 | # - Operations: 115 | # - Production considerations: foo 116 | # - Contributing: 117 | # - Testing: testing.md 118 | # - Contributors: constributors.md 119 | 120 | extra: 121 | version: 122 | provider: mike 123 | default: latest 124 | social: 125 | - icon: fontawesome/brands/github 126 | link: https://github.com/estahn/k8s-image-swapper 127 | - icon: fontawesome/brands/docker 128 | link: https://github.com/estahn/k8s-image-swapper/pkgs/container/k8s-image-swapper 129 | - icon: fontawesome/brands/slack 130 | link: https://kubernetes.slack.com/archives/C04LETF7KEC 131 | - icon: fontawesome/brands/twitter 132 | link: https://twitter.com/estahn 133 | - icon: fontawesome/brands/linkedin 134 | link: https://www.linkedin.com/in/enricostahn 135 | analytics: 136 | provider: google 137 | property: G-BK225DNZVM 138 | feedback: 139 | title: Was this page helpful? 140 | ratings: 141 | - icon: material/emoticon-happy-outline 142 | name: This page was helpful 143 | data: 1 144 | note: >- 145 | Thanks for your feedback! 146 | - icon: material/emoticon-sad-outline 147 | name: This page could be improved 148 | data: 0 149 | note: >- 150 | Thanks for your feedback! Help us improve this page by 151 | using our feedback form. 152 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@semantic-release/changelog": "^6.0.3", 4 | "@semantic-release/exec": "^7.1.0", 5 | "@semantic-release/git": "^10.0.1", 6 | "conventional-changelog-conventionalcommits": "^9.0.0", 7 | "semantic-release": "^24.2.5" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 Enrico Stahn 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package config 23 | 24 | import ( 25 | "fmt" 26 | "strings" 27 | "time" 28 | 29 | "github.com/spf13/viper" 30 | 31 | "github.com/estahn/k8s-image-swapper/pkg/types" 32 | ) 33 | 34 | const DefaultImageCopyDeadline = 8 * time.Second 35 | 36 | type Config struct { 37 | LogLevel string `yaml:"logLevel" validate:"oneof=trace debug info warn error fatal"` 38 | LogFormat string `yaml:"logFormat" validate:"oneof=json console"` 39 | 40 | ListenAddress string 41 | 42 | DryRun bool `yaml:"dryRun"` 43 | ImageSwapPolicy string `yaml:"imageSwapPolicy" validate:"oneof=always exists"` 44 | ImageCopyPolicy string `yaml:"imageCopyPolicy" validate:"oneof=delayed immediate force none"` 45 | ImageCopyDeadline time.Duration `yaml:"imageCopyDeadline"` 46 | 47 | Source Source `yaml:"source"` 48 | Target Registry `yaml:"target"` 49 | 50 | TLSCertFile string 51 | TLSKeyFile string 52 | } 53 | 54 | type JMESPathFilter struct { 55 | JMESPath string `yaml:"jmespath"` 56 | } 57 | 58 | type Source struct { 59 | Registries []Registry `yaml:"registries"` 60 | Filters []JMESPathFilter `yaml:"filters"` 61 | } 62 | 63 | type Registry struct { 64 | Type string `yaml:"type"` 65 | AWS AWS `yaml:"aws"` 66 | GCP GCP `yaml:"gcp"` 67 | } 68 | 69 | type AWS struct { 70 | AccountID string `yaml:"accountId"` 71 | Region string `yaml:"region"` 72 | Role string `yaml:"role"` 73 | ECROptions ECROptions `yaml:"ecrOptions"` 74 | } 75 | 76 | type GCP struct { 77 | Location string `yaml:"location"` 78 | ProjectID string `yaml:"projectId"` 79 | RepositoryID string `yaml:"repositoryId"` 80 | } 81 | 82 | type ECROptions struct { 83 | AccessPolicy string `yaml:"accessPolicy"` 84 | LifecyclePolicy string `yaml:"lifecyclePolicy"` 85 | Tags []Tag `yaml:"tags"` 86 | ImageTagMutability string `yaml:"imageTagMutability" validate:"oneof=MUTABLE IMMUTABLE"` 87 | ImageScanningConfiguration ImageScanningConfiguration `yaml:"imageScanningConfiguration"` 88 | EncryptionConfiguration EncryptionConfiguration `yaml:"encryptionConfiguration"` 89 | } 90 | 91 | type Tag struct { 92 | Key string `yaml:"key"` 93 | Value string `yaml:"value"` 94 | } 95 | 96 | type ImageScanningConfiguration struct { 97 | ImageScanOnPush bool `yaml:"imageScanOnPush"` 98 | } 99 | 100 | type EncryptionConfiguration struct { 101 | EncryptionType string `yaml:"encryptionType" validate:"oneof=KMS AES256"` 102 | KmsKey string `yaml:"kmsKey"` 103 | } 104 | 105 | func (a *AWS) EcrDomain() string { 106 | domain := "amazonaws.com" 107 | if strings.HasPrefix(a.Region, "cn-") { 108 | domain = "amazonaws.com.cn" 109 | } 110 | return fmt.Sprintf("%s.dkr.ecr.%s.%s", a.AccountID, a.Region, domain) 111 | } 112 | 113 | func (g *GCP) GarDomain() string { 114 | return fmt.Sprintf("%s-docker.pkg.dev/%s/%s", g.Location, g.ProjectID, g.RepositoryID) 115 | } 116 | 117 | func (r Registry) Domain() string { 118 | registry, _ := types.ParseRegistry(r.Type) 119 | switch registry { 120 | case types.RegistryAWS: 121 | return r.AWS.EcrDomain() 122 | case types.RegistryGCP: 123 | return r.GCP.GarDomain() 124 | default: 125 | return "" 126 | } 127 | } 128 | 129 | // provides detailed information about wrongly provided configuration 130 | func CheckRegistryConfiguration(r Registry) error { 131 | if r.Type == "" { 132 | return fmt.Errorf("a registry requires a type") 133 | } 134 | 135 | errorWithType := func(info string) error { 136 | return fmt.Errorf(`registry of type "%s" %s`, r.Type, info) 137 | } 138 | 139 | registry, _ := types.ParseRegistry(r.Type) 140 | switch registry { 141 | case types.RegistryAWS: 142 | if r.AWS.Region == "" { 143 | return errorWithType(`requires a field "region"`) 144 | } 145 | if r.AWS.AccountID == "" { 146 | return errorWithType(`requires a field "accountdId"`) 147 | } 148 | if r.AWS.ECROptions.EncryptionConfiguration.EncryptionType == "KMS" && r.AWS.ECROptions.EncryptionConfiguration.KmsKey == "" { 149 | return errorWithType(`requires a field "kmsKey" if encryptionType is set to "KMS"`) 150 | } 151 | case types.RegistryGCP: 152 | if r.GCP.Location == "" { 153 | return errorWithType(`requires a field "location"`) 154 | } 155 | if r.GCP.ProjectID == "" { 156 | return errorWithType(`requires a field "projectId"`) 157 | } 158 | if r.GCP.RepositoryID == "" { 159 | return errorWithType(`requires a field "repositoryId"`) 160 | } 161 | } 162 | 163 | return nil 164 | } 165 | 166 | // SetViperDefaults configures default values for config items that are not set. 167 | func SetViperDefaults(v *viper.Viper) { 168 | v.SetDefault("Target.Type", "aws") 169 | v.SetDefault("Target.AWS.ECROptions.ImageScanningConfiguration.ImageScanOnPush", true) 170 | v.SetDefault("Target.AWS.ECROptions.ImageTagMutability", "MUTABLE") 171 | v.SetDefault("Target.AWS.ECROptions.EncryptionConfiguration.EncryptionType", "AES256") 172 | } 173 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/spf13/viper" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | // TestConfigParses validates if yaml annotation do not overlap 13 | func TestConfigParses(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | cfg string 17 | expCfg Config 18 | expErr bool 19 | }{ 20 | { 21 | name: "should render empty config with defaults", 22 | cfg: "", 23 | expCfg: Config{ 24 | Target: Registry{ 25 | Type: "aws", 26 | AWS: AWS{ 27 | ECROptions: ECROptions{ 28 | ImageTagMutability: "MUTABLE", 29 | ImageScanningConfiguration: ImageScanningConfiguration{ 30 | ImageScanOnPush: true, 31 | }, 32 | EncryptionConfiguration: EncryptionConfiguration{ 33 | EncryptionType: "AES256", 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | { 41 | name: "should render multiple filters", 42 | cfg: ` 43 | source: 44 | filters: 45 | - jmespath: "obj.metadata.namespace == 'kube-system'" 46 | - jmespath: "obj.metadata.namespace != 'playground'" 47 | `, 48 | expCfg: Config{ 49 | Target: Registry{ 50 | Type: "aws", 51 | AWS: AWS{ 52 | ECROptions: ECROptions{ 53 | ImageTagMutability: "MUTABLE", 54 | ImageScanningConfiguration: ImageScanningConfiguration{ 55 | ImageScanOnPush: true, 56 | }, 57 | EncryptionConfiguration: EncryptionConfiguration{ 58 | EncryptionType: "AES256", 59 | }, 60 | }, 61 | }, 62 | }, 63 | Source: Source{ 64 | Filters: []JMESPathFilter{ 65 | {JMESPath: "obj.metadata.namespace == 'kube-system'"}, 66 | {JMESPath: "obj.metadata.namespace != 'playground'"}, 67 | }, 68 | }, 69 | }, 70 | }, 71 | { 72 | name: "should render tags config", 73 | cfg: ` 74 | target: 75 | type: aws 76 | aws: 77 | accountId: 123456789 78 | region: ap-southeast-2 79 | role: arn:aws:iam::123456789012:role/roleName 80 | ecrOptions: 81 | tags: 82 | - key: CreatedBy 83 | value: k8s-image-swapper 84 | - key: A 85 | value: B 86 | `, 87 | expCfg: Config{ 88 | Target: Registry{ 89 | Type: "aws", 90 | AWS: AWS{ 91 | AccountID: "123456789", 92 | Region: "ap-southeast-2", 93 | Role: "arn:aws:iam::123456789012:role/roleName", 94 | ECROptions: ECROptions{ 95 | ImageTagMutability: "MUTABLE", 96 | ImageScanningConfiguration: ImageScanningConfiguration{ 97 | ImageScanOnPush: true, 98 | }, 99 | EncryptionConfiguration: EncryptionConfiguration{ 100 | EncryptionType: "AES256", 101 | }, 102 | Tags: []Tag{ 103 | { 104 | Key: "CreatedBy", 105 | Value: "k8s-image-swapper", 106 | }, 107 | { 108 | Key: "A", 109 | Value: "B", 110 | }, 111 | }, 112 | }, 113 | }, 114 | }, 115 | }, 116 | }, 117 | { 118 | name: "should render multiple source registries", 119 | cfg: ` 120 | source: 121 | registries: 122 | - type: "aws" 123 | aws: 124 | accountId: "12345678912" 125 | region: "us-west-1" 126 | - type: "aws" 127 | aws: 128 | accountId: "12345678912" 129 | region: "us-east-1" 130 | `, 131 | expCfg: Config{ 132 | Target: Registry{ 133 | Type: "aws", 134 | AWS: AWS{ 135 | ECROptions: ECROptions{ 136 | ImageTagMutability: "MUTABLE", 137 | ImageScanningConfiguration: ImageScanningConfiguration{ 138 | ImageScanOnPush: true, 139 | }, 140 | EncryptionConfiguration: EncryptionConfiguration{ 141 | EncryptionType: "AES256", 142 | }, 143 | }, 144 | }, 145 | }, 146 | Source: Source{ 147 | Registries: []Registry{ 148 | { 149 | Type: "aws", 150 | AWS: AWS{ 151 | AccountID: "12345678912", 152 | Region: "us-west-1", 153 | }}, 154 | { 155 | Type: "aws", 156 | AWS: AWS{ 157 | AccountID: "12345678912", 158 | Region: "us-east-1", 159 | }}, 160 | }, 161 | }, 162 | }, 163 | }, 164 | { 165 | name: "should use previous defaults", 166 | cfg: ` 167 | target: 168 | type: aws 169 | aws: 170 | accountId: 123456789 171 | region: ap-southeast-2 172 | role: arn:aws:iam::123456789012:role/roleName 173 | ecrOptions: 174 | tags: 175 | - key: CreatedBy 176 | value: k8s-image-swapper 177 | - key: A 178 | value: B 179 | `, 180 | expCfg: Config{ 181 | Target: Registry{ 182 | Type: "aws", 183 | AWS: AWS{ 184 | AccountID: "123456789", 185 | Region: "ap-southeast-2", 186 | Role: "arn:aws:iam::123456789012:role/roleName", 187 | ECROptions: ECROptions{ 188 | ImageScanningConfiguration: ImageScanningConfiguration{ 189 | ImageScanOnPush: true, 190 | }, 191 | EncryptionConfiguration: EncryptionConfiguration{ 192 | EncryptionType: "AES256", 193 | }, 194 | ImageTagMutability: "MUTABLE", 195 | Tags: []Tag{ 196 | { 197 | Key: "CreatedBy", 198 | Value: "k8s-image-swapper", 199 | }, 200 | { 201 | Key: "A", 202 | Value: "B", 203 | }, 204 | }, 205 | }, 206 | }, 207 | }, 208 | }, 209 | }, 210 | } 211 | 212 | for _, test := range tests { 213 | t.Run(test.name, func(t *testing.T) { 214 | assert := assert.New(t) 215 | 216 | v := viper.New() 217 | v.SetConfigType("yaml") 218 | SetViperDefaults(v) 219 | 220 | readConfigError := v.ReadConfig(strings.NewReader(test.cfg)) 221 | assert.NoError(readConfigError) 222 | 223 | gotCfg := Config{} 224 | err := v.Unmarshal(&gotCfg) 225 | 226 | if test.expErr { 227 | assert.Error(err) 228 | } else if assert.NoError(err) { 229 | assert.Equal(test.expCfg, gotCfg) 230 | } 231 | }) 232 | } 233 | } 234 | 235 | func TestEcrDomain(t *testing.T) { 236 | tests := []struct { 237 | name string 238 | cfg Config 239 | domain string 240 | }{ 241 | { 242 | name: "commercial aws", 243 | cfg: Config{ 244 | Target: Registry{ 245 | Type: "aws", 246 | AWS: AWS{ 247 | AccountID: "123456789", 248 | Region: "ap-southeast-2", 249 | }, 250 | }, 251 | }, 252 | domain: "123456789.dkr.ecr.ap-southeast-2.amazonaws.com", 253 | }, 254 | { 255 | name: "aws in china", 256 | cfg: Config{ 257 | Target: Registry{ 258 | Type: "aws", 259 | AWS: AWS{ 260 | AccountID: "123456789", 261 | Region: "cn-north-1", 262 | }, 263 | }, 264 | }, 265 | domain: "123456789.dkr.ecr.cn-north-1.amazonaws.com.cn", 266 | }, 267 | } 268 | for _, test := range tests { 269 | t.Run(test.name, func(t *testing.T) { 270 | assert := assert.New(t) 271 | assert.Equal(test.cfg.Target.AWS.EcrDomain(), test.domain) 272 | }) 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /pkg/registry/client.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | 9 | "github.com/estahn/k8s-image-swapper/pkg/config" 10 | "github.com/estahn/k8s-image-swapper/pkg/types" 11 | 12 | ctypes "github.com/containers/image/v5/types" 13 | ) 14 | 15 | // Client provides methods required to be implemented by the various target registry clients, e.g. ECR, Docker, Quay. 16 | type Client interface { 17 | CreateRepository(ctx context.Context, name string) error 18 | RepositoryExists() bool 19 | CopyImage(ctx context.Context, src ctypes.ImageReference, srcCreds string, dest ctypes.ImageReference, destCreds string) error 20 | PullImage() error 21 | PutImage() error 22 | ImageExists(ctx context.Context, ref ctypes.ImageReference) bool 23 | 24 | // Endpoint returns the domain of the registry 25 | Endpoint() string 26 | Credentials() string 27 | 28 | // IsOrigin returns true if the imageRef originates from this registry 29 | IsOrigin(imageRef ctypes.ImageReference) bool 30 | } 31 | 32 | type DockerConfig struct { 33 | AuthConfigs map[string]AuthConfig `json:"auths"` 34 | } 35 | 36 | type AuthConfig struct { 37 | Auth string `json:"auth,omitempty"` 38 | } 39 | 40 | // NewClient returns a registry client ready for use without the need to specify an implementation 41 | func NewClient(r config.Registry) (Client, error) { 42 | if err := config.CheckRegistryConfiguration(r); err != nil { 43 | return nil, err 44 | } 45 | 46 | registry, err := types.ParseRegistry(r.Type) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | switch registry { 52 | case types.RegistryAWS: 53 | return NewECRClient(r.AWS) 54 | case types.RegistryGCP: 55 | return NewGARClient(r.GCP) 56 | default: 57 | return nil, fmt.Errorf(`registry of type "%s" is not supported`, r.Type) 58 | } 59 | } 60 | 61 | func GenerateDockerConfig(c Client) ([]byte, error) { 62 | dockerConfig := DockerConfig{ 63 | AuthConfigs: map[string]AuthConfig{ 64 | c.Endpoint(): { 65 | Auth: base64.StdEncoding.EncodeToString([]byte(c.Credentials())), 66 | }, 67 | }, 68 | } 69 | 70 | dockerConfigJson, err := json.Marshal(dockerConfig) 71 | if err != nil { 72 | return []byte{}, err 73 | } 74 | 75 | return dockerConfigJson, nil 76 | } 77 | -------------------------------------------------------------------------------- /pkg/registry/ecr.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "math/rand" 8 | "net/http" 9 | "os/exec" 10 | "time" 11 | 12 | "github.com/containers/image/v5/docker/reference" 13 | 14 | "github.com/aws/aws-sdk-go/aws" 15 | "github.com/aws/aws-sdk-go/aws/awserr" 16 | "github.com/aws/aws-sdk-go/aws/credentials/stscreds" 17 | "github.com/aws/aws-sdk-go/aws/session" 18 | "github.com/aws/aws-sdk-go/service/ecr" 19 | "github.com/aws/aws-sdk-go/service/ecr/ecriface" 20 | ctypes "github.com/containers/image/v5/types" 21 | "github.com/dgraph-io/ristretto" 22 | "github.com/estahn/k8s-image-swapper/pkg/config" 23 | "github.com/go-co-op/gocron" 24 | "github.com/rs/zerolog/log" 25 | ) 26 | 27 | type ECRClient struct { 28 | client ecriface.ECRAPI 29 | ecrDomain string 30 | authToken []byte 31 | cache *ristretto.Cache 32 | scheduler *gocron.Scheduler 33 | targetAccount string 34 | options config.ECROptions 35 | } 36 | 37 | func NewECRClient(clientConfig config.AWS) (*ECRClient, error) { 38 | ecrDomain := clientConfig.EcrDomain() 39 | 40 | var sess *session.Session 41 | var cfg *aws.Config 42 | if clientConfig.Role != "" { 43 | log.Info().Str("assumedRole", clientConfig.Role).Msg("assuming specified role") 44 | stsSession, _ := session.NewSession(cfg) 45 | creds := stscreds.NewCredentials(stsSession, clientConfig.Role) 46 | cfg = aws.NewConfig(). 47 | WithRegion(clientConfig.Region). 48 | WithCredentialsChainVerboseErrors(true). 49 | WithHTTPClient(&http.Client{ 50 | Timeout: 3 * time.Second, 51 | }). 52 | WithCredentials(creds) 53 | } else { 54 | cfg = aws.NewConfig(). 55 | WithRegion(clientConfig.Region). 56 | WithCredentialsChainVerboseErrors(true). 57 | WithHTTPClient(&http.Client{ 58 | Timeout: 3 * time.Second, 59 | }) 60 | } 61 | 62 | sess = session.Must(session.NewSessionWithOptions(session.Options{ 63 | SharedConfigState: session.SharedConfigEnable, 64 | Config: *cfg, 65 | })) 66 | ecrClient := ecr.New(sess, cfg) 67 | 68 | cache, err := ristretto.NewCache(&ristretto.Config{ 69 | NumCounters: 1e7, // number of keys to track frequency of (10M). 70 | MaxCost: 1 << 30, // maximum cost of cache (1GB). 71 | BufferItems: 64, // number of keys per Get buffer. 72 | }) 73 | if err != nil { 74 | panic(err) 75 | } 76 | 77 | scheduler := gocron.NewScheduler(time.UTC) 78 | scheduler.StartAsync() 79 | 80 | client := &ECRClient{ 81 | client: ecrClient, 82 | ecrDomain: ecrDomain, 83 | cache: cache, 84 | scheduler: scheduler, 85 | targetAccount: clientConfig.AccountID, 86 | options: clientConfig.ECROptions, 87 | } 88 | 89 | if err := client.scheduleTokenRenewal(); err != nil { 90 | return nil, err 91 | } 92 | 93 | return client, nil 94 | } 95 | 96 | func (e *ECRClient) Credentials() string { 97 | return string(e.authToken) 98 | } 99 | 100 | func (e *ECRClient) CreateRepository(ctx context.Context, name string) error { 101 | if _, found := e.cache.Get(name); found { 102 | return nil 103 | } 104 | 105 | log.Ctx(ctx).Debug().Str("repository", name).Msg("create repository") 106 | 107 | encryptionConfiguration := &ecr.EncryptionConfiguration{ 108 | EncryptionType: aws.String(e.options.EncryptionConfiguration.EncryptionType), 109 | } 110 | 111 | if e.options.EncryptionConfiguration.EncryptionType == "KMS" { 112 | encryptionConfiguration.KmsKey = aws.String(e.options.EncryptionConfiguration.KmsKey) 113 | } 114 | 115 | _, err := e.client.CreateRepositoryWithContext(ctx, &ecr.CreateRepositoryInput{ 116 | RepositoryName: aws.String(name), 117 | EncryptionConfiguration: encryptionConfiguration, 118 | ImageScanningConfiguration: &ecr.ImageScanningConfiguration{ 119 | ScanOnPush: aws.Bool(e.options.ImageScanningConfiguration.ImageScanOnPush), 120 | }, 121 | ImageTagMutability: aws.String(e.options.ImageTagMutability), 122 | RegistryId: &e.targetAccount, 123 | Tags: e.buildEcrTags(), 124 | }) 125 | 126 | if err != nil { 127 | if aerr, ok := err.(awserr.Error); ok { 128 | switch aerr.Code() { 129 | case ecr.ErrCodeRepositoryAlreadyExistsException: 130 | // We ignore this case as it is valid. 131 | default: 132 | return err 133 | } 134 | } else { 135 | // Print the error, cast err to awserr.Error to get the Code and 136 | // Message from an error. 137 | return err 138 | } 139 | } 140 | 141 | if len(e.options.AccessPolicy) > 0 { 142 | log.Ctx(ctx).Debug().Str("repo", name).Str("accessPolicy", e.options.AccessPolicy).Msg("setting access policy on repo") 143 | _, err := e.client.SetRepositoryPolicyWithContext(ctx, &ecr.SetRepositoryPolicyInput{ 144 | PolicyText: &e.options.AccessPolicy, 145 | RegistryId: &e.targetAccount, 146 | RepositoryName: aws.String(name), 147 | }) 148 | 149 | if err != nil { 150 | log.Err(err).Msg(err.Error()) 151 | return err 152 | } 153 | } 154 | 155 | if len(e.options.LifecyclePolicy) > 0 { 156 | log.Ctx(ctx).Debug().Str("repo", name).Str("lifecyclePolicy", e.options.LifecyclePolicy).Msg("setting lifecycle policy on repo") 157 | _, err := e.client.PutLifecyclePolicyWithContext(ctx, &ecr.PutLifecyclePolicyInput{ 158 | LifecyclePolicyText: &e.options.LifecyclePolicy, 159 | RegistryId: &e.targetAccount, 160 | RepositoryName: aws.String(name), 161 | }) 162 | 163 | if err != nil { 164 | log.Err(err).Msg(err.Error()) 165 | return err 166 | } 167 | } 168 | 169 | e.cache.SetWithTTL(name, "", 1, time.Duration(24*time.Hour)) 170 | 171 | return nil 172 | } 173 | 174 | func (e *ECRClient) buildEcrTags() []*ecr.Tag { 175 | ecrTags := []*ecr.Tag{} 176 | 177 | for _, t := range e.options.Tags { 178 | tag := ecr.Tag{Key: aws.String(t.Key), Value: aws.String(t.Value)} 179 | ecrTags = append(ecrTags, &tag) 180 | } 181 | 182 | return ecrTags 183 | } 184 | 185 | func (e *ECRClient) RepositoryExists() bool { 186 | panic("implement me") 187 | } 188 | 189 | func (e *ECRClient) CopyImage(ctx context.Context, srcRef ctypes.ImageReference, srcCreds string, destRef ctypes.ImageReference, destCreds string) error { 190 | src := srcRef.DockerReference().String() 191 | dest := destRef.DockerReference().String() 192 | app := "skopeo" 193 | args := []string{ 194 | "--override-os", "linux", 195 | "copy", 196 | "--multi-arch", "all", 197 | "--retry-times", "3", 198 | "docker://" + src, 199 | "docker://" + dest, 200 | } 201 | 202 | if len(srcCreds) > 0 { 203 | args = append(args, "--src-authfile", srcCreds) 204 | } else { 205 | args = append(args, "--src-no-creds") 206 | } 207 | 208 | if len(destCreds) > 0 { 209 | args = append(args, "--dest-creds", destCreds) 210 | } else { 211 | args = append(args, "--dest-no-creds") 212 | } 213 | 214 | log.Ctx(ctx). 215 | Trace(). 216 | Str("app", app). 217 | Strs("args", args). 218 | Msg("execute command to copy image") 219 | 220 | output, cmdErr := exec.CommandContext(ctx, app, args...).CombinedOutput() 221 | 222 | // check if the command timed out during execution for proper logging 223 | if err := ctx.Err(); err != nil { 224 | return err 225 | } 226 | 227 | // enrich error with output from the command which may contain the actual reason 228 | if cmdErr != nil { 229 | return fmt.Errorf("Command error, stderr: %s, stdout: %s", cmdErr.Error(), string(output)) 230 | } 231 | 232 | return nil 233 | } 234 | 235 | func (e *ECRClient) PullImage() error { 236 | panic("implement me") 237 | } 238 | 239 | func (e *ECRClient) PutImage() error { 240 | panic("implement me") 241 | } 242 | 243 | func (e *ECRClient) ImageExists(ctx context.Context, imageRef ctypes.ImageReference) bool { 244 | ref := imageRef.DockerReference().String() 245 | if _, found := e.cache.Get(ref); found { 246 | log.Ctx(ctx).Trace().Str("ref", ref).Msg("found in cache") 247 | return true 248 | } 249 | 250 | app := "skopeo" 251 | args := []string{ 252 | "inspect", 253 | "--retry-times", "3", 254 | "docker://" + ref, 255 | "--creds", e.Credentials(), 256 | } 257 | 258 | log.Ctx(ctx).Trace().Str("app", app).Strs("args", args).Msg("executing command to inspect image") 259 | if err := exec.CommandContext(ctx, app, args...).Run(); err != nil { 260 | log.Ctx(ctx).Trace().Str("ref", ref).Msg("not found in target repository") 261 | return false 262 | } 263 | 264 | log.Ctx(ctx).Trace().Str("ref", ref).Msg("found in target repository") 265 | 266 | e.cache.SetWithTTL(ref, "", 1, 24*time.Hour+time.Duration(rand.Intn(180))*time.Minute) 267 | 268 | return true 269 | } 270 | 271 | func (e *ECRClient) Endpoint() string { 272 | return e.ecrDomain 273 | } 274 | 275 | // IsOrigin returns true if the references origin is from this registry 276 | func (e *ECRClient) IsOrigin(imageRef ctypes.ImageReference) bool { 277 | domain := reference.Domain(imageRef.DockerReference()) 278 | return domain == e.Endpoint() 279 | } 280 | 281 | // requestAuthToken requests and returns an authentication token from ECR with its expiration date 282 | func (e *ECRClient) requestAuthToken() ([]byte, time.Time, error) { 283 | getAuthTokenOutput, err := e.client.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{ 284 | RegistryIds: []*string{&e.targetAccount}, 285 | }) 286 | 287 | if err != nil { 288 | return []byte(""), time.Time{}, err 289 | } 290 | 291 | authToken, err := base64.StdEncoding.DecodeString(*getAuthTokenOutput.AuthorizationData[0].AuthorizationToken) 292 | if err != nil { 293 | return []byte(""), time.Time{}, err 294 | } 295 | 296 | return authToken, *getAuthTokenOutput.AuthorizationData[0].ExpiresAt, nil 297 | } 298 | 299 | // scheduleTokenRenewal sets a scheduler to execute token renewal before the token expires 300 | func (e *ECRClient) scheduleTokenRenewal() error { 301 | token, expiryAt, err := e.requestAuthToken() 302 | if err != nil { 303 | return err 304 | } 305 | 306 | renewalAt := expiryAt.Add(-2 * time.Minute) 307 | e.authToken = token 308 | 309 | log.Debug().Time("expiryAt", expiryAt).Time("renewalAt", renewalAt).Msg("auth token set, schedule next token renewal") 310 | 311 | j, _ := e.scheduler.Every(1).StartAt(renewalAt).Do(e.scheduleTokenRenewal) 312 | j.LimitRunsTo(1) 313 | 314 | return nil 315 | } 316 | 317 | // For testing purposes 318 | func NewDummyECRClient(region string, targetAccount string, role string, options config.ECROptions, authToken []byte) *ECRClient { 319 | return &ECRClient{ 320 | targetAccount: targetAccount, 321 | options: options, 322 | ecrDomain: fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com", targetAccount, region), 323 | authToken: authToken, 324 | } 325 | } 326 | 327 | func NewMockECRClient(ecrClient ecriface.ECRAPI, region string, ecrDomain string, targetAccount, role string) (*ECRClient, error) { 328 | client := &ECRClient{ 329 | client: ecrClient, 330 | ecrDomain: ecrDomain, 331 | cache: nil, 332 | scheduler: nil, 333 | targetAccount: targetAccount, 334 | authToken: []byte("mock-ecr-client-fake-auth-token"), 335 | options: config.ECROptions{ 336 | ImageTagMutability: "MUTABLE", 337 | ImageScanningConfiguration: config.ImageScanningConfiguration{ImageScanOnPush: true}, 338 | EncryptionConfiguration: config.EncryptionConfiguration{EncryptionType: "AES256"}, 339 | Tags: []config.Tag{{Key: "CreatedBy", Value: "k8s-image-swapper"}, {Key: "AnotherTag", Value: "another-tag"}}, 340 | }, 341 | } 342 | 343 | return client, nil 344 | } 345 | -------------------------------------------------------------------------------- /pkg/registry/ecr_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "encoding/base64" 5 | "testing" 6 | 7 | "github.com/containers/image/v5/transports/alltransports" 8 | 9 | "github.com/estahn/k8s-image-swapper/pkg/config" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestDockerConfig(t *testing.T) { 14 | fakeToken := []byte("token") 15 | fakeBase64Token := base64.StdEncoding.EncodeToString(fakeToken) 16 | 17 | expected := []byte("{\"auths\":{\"12345678912.dkr.ecr.us-east-1.amazonaws.com\":{\"auth\":\"" + fakeBase64Token + "\"}}}") 18 | 19 | fakeRegistry := NewDummyECRClient("us-east-1", "12345678912", "", config.ECROptions{}, fakeToken) 20 | 21 | r, _ := GenerateDockerConfig(fakeRegistry) 22 | 23 | assert.Equal(t, r, expected) 24 | } 25 | 26 | func TestECRIsOrigin(t *testing.T) { 27 | type testCase struct { 28 | input string 29 | expected bool 30 | } 31 | testcases := []testCase{ 32 | { 33 | input: "k8s.gcr.io/ingress-nginx/controller@sha256:9bba603b99bf25f6d117cf1235b6598c16033ad027b143c90fa5b3cc583c5713", 34 | expected: false, 35 | }, 36 | { 37 | input: "12345678912.dkr.ecr.us-east-1.amazonaws.com/k8s.gcr.io/ingress-nginx/controller@sha256:9bba603b99bf25f6d117cf1235b6598c16033ad027b143c90fa5b3cc583c5713", 38 | expected: true, 39 | }, 40 | } 41 | 42 | fakeRegistry := NewDummyECRClient("us-east-1", "12345678912", "", config.ECROptions{}, []byte("")) 43 | 44 | for _, testcase := range testcases { 45 | imageRef, err := alltransports.ParseImageName("docker://" + testcase.input) 46 | 47 | assert.NoError(t, err) 48 | 49 | result := fakeRegistry.IsOrigin(imageRef) 50 | 51 | assert.Equal(t, testcase.expected, result) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/registry/gar.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "math/rand" 9 | "os/exec" 10 | "strings" 11 | "time" 12 | 13 | artifactregistry "cloud.google.com/go/artifactregistry/apiv1" 14 | "github.com/containers/image/v5/docker/reference" 15 | ctypes "github.com/containers/image/v5/types" 16 | "github.com/dgraph-io/ristretto" 17 | "github.com/estahn/k8s-image-swapper/pkg/config" 18 | "github.com/go-co-op/gocron" 19 | "google.golang.org/api/option" 20 | "google.golang.org/api/transport" 21 | 22 | "github.com/rs/zerolog/log" 23 | ) 24 | 25 | type GARAPI interface{} 26 | 27 | type GARClient struct { 28 | client GARAPI 29 | garDomain string 30 | cache *ristretto.Cache 31 | scheduler *gocron.Scheduler 32 | authToken []byte 33 | } 34 | 35 | func NewGARClient(clientConfig config.GCP) (*GARClient, error) { 36 | cache, err := ristretto.NewCache(&ristretto.Config{ 37 | NumCounters: 1e7, // number of keys to track frequency of (10M). 38 | MaxCost: 1 << 30, // maximum cost of cache (1GB). 39 | BufferItems: 64, // number of keys per Get buffer. 40 | }) 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | scheduler := gocron.NewScheduler(time.UTC) 46 | scheduler.StartAsync() 47 | 48 | client := &GARClient{ 49 | client: nil, 50 | garDomain: clientConfig.GarDomain(), 51 | cache: cache, 52 | scheduler: scheduler, 53 | } 54 | 55 | if err := client.scheduleTokenRenewal(); err != nil { 56 | return nil, err 57 | } 58 | 59 | return client, nil 60 | } 61 | 62 | // CreateRepository is empty since repositories are not created for artifact registry 63 | func (e *GARClient) CreateRepository(ctx context.Context, name string) error { 64 | return nil 65 | } 66 | 67 | func (e *GARClient) RepositoryExists() bool { 68 | panic("implement me") 69 | } 70 | 71 | func (e *GARClient) CopyImage(ctx context.Context, srcRef ctypes.ImageReference, srcCreds string, destRef ctypes.ImageReference, destCreds string) error { 72 | src := srcRef.DockerReference().String() 73 | dest := destRef.DockerReference().String() 74 | 75 | creds := []string{"--src-authfile", srcCreds} 76 | 77 | // use client credentials for any source GAR repositories 78 | if strings.HasSuffix(reference.Domain(srcRef.DockerReference()), "-docker.pkg.dev") { 79 | creds = []string{"--src-creds", e.Credentials()} 80 | } 81 | 82 | app := "skopeo" 83 | args := []string{ 84 | "--override-os", "linux", 85 | "copy", 86 | "--multi-arch", "all", 87 | "--retry-times", "3", 88 | "docker://" + src, 89 | "docker://" + dest, 90 | } 91 | 92 | if len(creds[1]) > 0 { 93 | args = append(args, creds...) 94 | } else { 95 | args = append(args, "--src-no-creds") 96 | } 97 | 98 | if len(destCreds) > 0 { 99 | args = append(args, "--dest-creds", destCreds) 100 | } else { 101 | args = append(args, "--dest-no-creds") 102 | } 103 | 104 | log.Ctx(ctx). 105 | Trace(). 106 | Str("app", app). 107 | Strs("args", args). 108 | Msg("execute command to copy image") 109 | 110 | output, cmdErr := exec.CommandContext(ctx, app, args...).CombinedOutput() 111 | 112 | // check if the command timed out during execution for proper logging 113 | if err := ctx.Err(); err != nil { 114 | return err 115 | } 116 | 117 | // enrich error with output from the command which may contain the actual reason 118 | if cmdErr != nil { 119 | return fmt.Errorf("Command error, stderr: %s, stdout: %s", cmdErr.Error(), string(output)) 120 | } 121 | 122 | return nil 123 | } 124 | 125 | func (e *GARClient) PullImage() error { 126 | panic("implement me") 127 | } 128 | 129 | func (e *GARClient) PutImage() error { 130 | panic("implement me") 131 | } 132 | 133 | func (e *GARClient) ImageExists(ctx context.Context, imageRef ctypes.ImageReference) bool { 134 | ref := imageRef.DockerReference().String() 135 | if _, found := e.cache.Get(ref); found { 136 | log.Ctx(ctx).Trace().Str("ref", ref).Msg("found in cache") 137 | return true 138 | } 139 | 140 | app := "skopeo" 141 | args := []string{ 142 | "inspect", 143 | "--retry-times", "3", 144 | "docker://" + ref, 145 | "--creds", e.Credentials(), 146 | } 147 | 148 | log.Ctx(ctx).Trace().Str("app", app).Strs("args", args).Msg("executing command to inspect image") 149 | if err := exec.CommandContext(ctx, app, args...).Run(); err != nil { 150 | log.Trace().Str("ref", ref).Msg("not found in target repository") 151 | return false 152 | } 153 | 154 | log.Ctx(ctx).Trace().Str("ref", ref).Msg("found in target repository") 155 | 156 | e.cache.SetWithTTL(ref, "", 1, 24*time.Hour+time.Duration(rand.Intn(180))*time.Minute) 157 | 158 | return true 159 | } 160 | 161 | func (e *GARClient) Endpoint() string { 162 | return e.garDomain 163 | } 164 | 165 | // IsOrigin returns true if the references origin is from this registry 166 | func (e *GARClient) IsOrigin(imageRef ctypes.ImageReference) bool { 167 | return strings.HasPrefix(imageRef.DockerReference().String(), e.Endpoint()) 168 | } 169 | 170 | // requestAuthToken requests and returns an authentication token from GAR with its expiration date 171 | func (e *GARClient) requestAuthToken() ([]byte, time.Time, error) { 172 | ctx := context.Background() 173 | creds, err := transport.Creds(ctx, option.WithScopes(artifactregistry.DefaultAuthScopes()...)) 174 | if err != nil { 175 | log.Err(err).Msg("generating gcp creds") 176 | return []byte(""), time.Time{}, err 177 | } 178 | token, err := creds.TokenSource.Token() 179 | if err != nil { 180 | log.Err(err).Msg("generating token") 181 | return []byte(""), time.Time{}, err 182 | } 183 | 184 | return []byte(fmt.Sprintf("oauth2accesstoken:%v", token.AccessToken)), token.Expiry, nil 185 | } 186 | 187 | // scheduleTokenRenewal sets a scheduler to execute token renewal before the token expires 188 | func (e *GARClient) scheduleTokenRenewal() error { 189 | token, expiryAt, err := e.requestAuthToken() 190 | if err != nil { 191 | return err 192 | } 193 | 194 | renewalAt := expiryAt.Add(-2 * time.Minute) 195 | e.authToken = token 196 | 197 | log.Debug().Time("expiryAt", expiryAt).Time("renewalAt", renewalAt).Msg("auth token set, schedule next token renewal") 198 | 199 | j, _ := e.scheduler.Every(1).StartAt(renewalAt).Do(e.scheduleTokenRenewal) 200 | j.LimitRunsTo(1) 201 | 202 | return nil 203 | } 204 | 205 | func (e *GARClient) Credentials() string { 206 | return string(e.authToken) 207 | } 208 | 209 | func (e *GARClient) DockerConfig() ([]byte, error) { 210 | dockerConfig := DockerConfig{ 211 | AuthConfigs: map[string]AuthConfig{ 212 | e.garDomain: { 213 | Auth: base64.StdEncoding.EncodeToString(e.authToken), 214 | }, 215 | }, 216 | } 217 | 218 | dockerConfigJson, err := json.Marshal(dockerConfig) 219 | if err != nil { 220 | return []byte{}, err 221 | } 222 | 223 | return dockerConfigJson, nil 224 | } 225 | 226 | func NewMockGARClient(garClient GARAPI, garDomain string) (*GARClient, error) { 227 | client := &GARClient{ 228 | client: garClient, 229 | garDomain: garDomain, 230 | cache: nil, 231 | scheduler: nil, 232 | authToken: []byte("oauth2accesstoken:mock-gar-client-fake-auth-token"), 233 | } 234 | 235 | return client, nil 236 | } 237 | -------------------------------------------------------------------------------- /pkg/registry/gar_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/containers/image/v5/transports/alltransports" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGARIsOrigin(t *testing.T) { 12 | type testCase struct { 13 | input string 14 | expected bool 15 | } 16 | testcases := []testCase{ 17 | { 18 | input: "k8s.gcr.io/ingress-nginx/controller@sha256:9bba603b99bf25f6d117cf1235b6598c16033ad027b143c90fa5b3cc583c5713", 19 | expected: false, 20 | }, 21 | { 22 | input: "us-central1-docker.pkg.dev/gcp-project-123/main/k8s.gcr.io/ingress-nginx/controller@sha256:9bba603b99bf25f6d117cf1235b6598c16033ad027b143c90fa5b3cc583c5713", 23 | expected: true, 24 | }, 25 | } 26 | 27 | fakeRegistry, _ := NewMockGARClient(nil, "us-central1-docker.pkg.dev/gcp-project-123/main") 28 | 29 | for _, testcase := range testcases { 30 | imageRef, err := alltransports.ParseImageName("docker://" + testcase.input) 31 | 32 | assert.NoError(t, err) 33 | 34 | result := fakeRegistry.IsOrigin(imageRef) 35 | 36 | assert.Equal(t, testcase.expected, result) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkg/registry/inmemory.go: -------------------------------------------------------------------------------- 1 | package registry 2 | -------------------------------------------------------------------------------- /pkg/secrets/dummy.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/estahn/k8s-image-swapper/pkg/registry" 7 | v1 "k8s.io/api/core/v1" 8 | ) 9 | 10 | // DummyImagePullSecretsProvider does nothing 11 | type DummyImagePullSecretsProvider struct { 12 | } 13 | 14 | // NewDummyImagePullSecretsProvider initialises a dummy image pull secrets provider 15 | func NewDummyImagePullSecretsProvider() ImagePullSecretsProvider { 16 | return &DummyImagePullSecretsProvider{} 17 | } 18 | 19 | func (p *DummyImagePullSecretsProvider) SetAuthenticatedRegistries(registries []registry.Client) { 20 | } 21 | 22 | // GetImagePullSecrets returns an empty ImagePullSecretsResult 23 | func (p *DummyImagePullSecretsProvider) GetImagePullSecrets(ctx context.Context, pod *v1.Pod) (*ImagePullSecretsResult, error) { 24 | return NewImagePullSecretsResult(), nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/secrets/dummy_test.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | corev1 "k8s.io/api/core/v1" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | ) 11 | 12 | func TestDummyImagePullSecretsProvider_GetImagePullSecrets(t *testing.T) { 13 | type args struct { 14 | pod *corev1.Pod 15 | } 16 | tests := []struct { 17 | name string 18 | args args 19 | want *ImagePullSecretsResult 20 | wantErr bool 21 | }{ 22 | { 23 | name: "default", 24 | args: args{ 25 | pod: &corev1.Pod{ 26 | ObjectMeta: metav1.ObjectMeta{ 27 | Namespace: "test-ns", 28 | Name: "my-pod", 29 | }, 30 | Spec: corev1.PodSpec{ 31 | ServiceAccountName: "my-service-account", 32 | ImagePullSecrets: []corev1.LocalObjectReference{ 33 | {Name: "my-pod-secret"}, 34 | }, 35 | }, 36 | }, 37 | }, 38 | want: NewImagePullSecretsResult(), 39 | wantErr: false, 40 | }, 41 | } 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | p := &DummyImagePullSecretsProvider{} 45 | got, err := p.GetImagePullSecrets(context.Background(), tt.args.pod) 46 | if (err != nil) != tt.wantErr { 47 | t.Errorf("GetImagePullSecrets() error = %v, wantErr %v", err, tt.wantErr) 48 | return 49 | } 50 | if !reflect.DeepEqual(got, tt.want) { 51 | t.Errorf("GetImagePullSecrets() got = %v, want %v", got, tt.want) 52 | } 53 | }) 54 | } 55 | } 56 | 57 | func TestNewDummyImagePullSecretsProvider(t *testing.T) { 58 | tests := []struct { 59 | name string 60 | want ImagePullSecretsProvider 61 | }{ 62 | { 63 | name: "default", 64 | want: &DummyImagePullSecretsProvider{}, 65 | }, 66 | } 67 | for _, tt := range tests { 68 | t.Run(tt.name, func(t *testing.T) { 69 | if got := NewDummyImagePullSecretsProvider(); !reflect.DeepEqual(got, tt.want) { 70 | t.Errorf("NewDummyImagePullSecretsProvider() = %v, want %v", got, tt.want) 71 | } 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/secrets/kubernetes.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/estahn/k8s-image-swapper/pkg/registry" 9 | jsonpatch "github.com/evanphx/json-patch" 10 | "github.com/rs/zerolog/log" 11 | v1 "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/client-go/kubernetes" 14 | ) 15 | 16 | // KubernetesImagePullSecretsProvider retrieves the secrets holding docker auth information from Kubernetes and merges 17 | // them if necessary. Supports Pod secrets as well as ServiceAccount secrets. 18 | type KubernetesImagePullSecretsProvider struct { 19 | kubernetesClient kubernetes.Interface 20 | authenticatedRegistries []registry.Client 21 | } 22 | 23 | // ImagePullSecretsResult contains the result of GetImagePullSecrets 24 | type ImagePullSecretsResult struct { 25 | Secrets map[string][]byte 26 | Aggregate []byte 27 | } 28 | 29 | // NewImagePullSecretsResult initialises ImagePullSecretsResult 30 | func NewImagePullSecretsResult() *ImagePullSecretsResult { 31 | return &ImagePullSecretsResult{ 32 | Secrets: map[string][]byte{}, 33 | Aggregate: []byte("{}"), 34 | } 35 | } 36 | 37 | // Initialiaze an ImagePullSecretsResult and registers image pull secrets from the given registries 38 | func NewImagePullSecretsResultWithDefaults(defaultImagePullSecrets []registry.Client) *ImagePullSecretsResult { 39 | imagePullSecretsResult := NewImagePullSecretsResult() 40 | for index, reg := range defaultImagePullSecrets { 41 | dockerConfig, err := registry.GenerateDockerConfig(reg) 42 | if err != nil { 43 | log.Err(err) 44 | } else { 45 | imagePullSecretsResult.Add(fmt.Sprintf("source-ecr-%d", index), dockerConfig) 46 | } 47 | } 48 | return imagePullSecretsResult 49 | } 50 | 51 | // Add a secrets to internal list and rebuilds the aggregate 52 | func (r *ImagePullSecretsResult) Add(name string, data []byte) { 53 | r.Secrets[name] = data 54 | r.Aggregate, _ = jsonpatch.MergePatch(r.Aggregate, data) 55 | } 56 | 57 | // AuthFile provides the aggregate as a file to be used by a docker client 58 | func (r *ImagePullSecretsResult) AuthFile() (*os.File, error) { 59 | tmpfile, err := os.CreateTemp("", "auth") 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | if _, err := tmpfile.Write(r.Aggregate); err != nil { 65 | return nil, err 66 | } 67 | if err := tmpfile.Close(); err != nil { 68 | return nil, err 69 | } 70 | 71 | return tmpfile, nil 72 | } 73 | 74 | func NewKubernetesImagePullSecretsProvider(clientset kubernetes.Interface) ImagePullSecretsProvider { 75 | return &KubernetesImagePullSecretsProvider{ 76 | kubernetesClient: clientset, 77 | authenticatedRegistries: []registry.Client{}, 78 | } 79 | } 80 | 81 | func (p *KubernetesImagePullSecretsProvider) SetAuthenticatedRegistries(registries []registry.Client) { 82 | p.authenticatedRegistries = registries 83 | } 84 | 85 | // GetImagePullSecrets returns all secrets with their respective content 86 | func (p *KubernetesImagePullSecretsProvider) GetImagePullSecrets(ctx context.Context, pod *v1.Pod) (*ImagePullSecretsResult, error) { 87 | var secrets = make(map[string][]byte) 88 | 89 | imagePullSecrets := pod.Spec.ImagePullSecrets 90 | 91 | // retrieve secret names from pod ServiceAccount (spec.imagePullSecrets) 92 | serviceAccount, err := p.kubernetesClient.CoreV1(). 93 | ServiceAccounts(pod.Namespace). 94 | Get(ctx, pod.Spec.ServiceAccountName, metav1.GetOptions{}) 95 | if err != nil { 96 | log.Ctx(ctx).Warn().Msg("error fetching referenced service account, continue without service account imagePullSecrets") 97 | } 98 | 99 | if serviceAccount != nil { 100 | imagePullSecrets = append(imagePullSecrets, serviceAccount.ImagePullSecrets...) 101 | } 102 | 103 | result := NewImagePullSecretsResultWithDefaults(p.authenticatedRegistries) 104 | for _, imagePullSecret := range imagePullSecrets { 105 | // fetch a secret only once 106 | if _, exists := secrets[imagePullSecret.Name]; exists { 107 | continue 108 | } 109 | 110 | secret, err := p.kubernetesClient.CoreV1().Secrets(pod.Namespace).Get(ctx, imagePullSecret.Name, metav1.GetOptions{}) 111 | if err != nil { 112 | log.Ctx(ctx).Err(err).Msg("error fetching secret, continue without imagePullSecrets") 113 | } 114 | 115 | if secret == nil || secret.Type != v1.SecretTypeDockerConfigJson { 116 | continue 117 | } 118 | 119 | result.Add(imagePullSecret.Name, secret.Data[v1.DockerConfigJsonKey]) 120 | } 121 | 122 | return result, nil 123 | } 124 | -------------------------------------------------------------------------------- /pkg/secrets/kubernetes_test.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/estahn/k8s-image-swapper/pkg/config" 10 | "github.com/estahn/k8s-image-swapper/pkg/registry" 11 | "github.com/stretchr/testify/assert" 12 | corev1 "k8s.io/api/core/v1" 13 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 | "k8s.io/client-go/kubernetes/fake" 15 | ) 16 | 17 | //type ExampleTestSuite struct { 18 | // suite.Suite 19 | //} 20 | // 21 | //func (suite *ExampleTestSuite) SetupTest() { 22 | //} 23 | //func (suite *ExampleTestSuite) TestExample() { 24 | // assert.Equal(suite.T(), 5, 1) 25 | //} 26 | // 27 | //func TestExampleTestSuite(t *testing.T) { 28 | // suite.Run(t, new(ExampleTestSuite)) 29 | //} 30 | 31 | // Test: 32 | //+------------------+-----+----------------+ 33 | //| | Pod | ServiceAccount | 34 | //+------------------+-----+----------------+ 35 | //| ImagePullSecrets | Y | Y | 36 | //+------------------+-----+----------------+ 37 | //| ImagePullSecrets | Y | N | 38 | //+------------------+-----+----------------+ 39 | //| ImagePullSecrets | N | Y | 40 | //+------------------+-----+----------------+ 41 | //| ImagePullSecrets | N | N | 42 | //+------------------+-----+----------------+ 43 | // 44 | // Multple image pull secrets on pod + service account 45 | // Pod secret should override service account secret 46 | 47 | func TestKubernetesCredentialProvider_GetImagePullSecrets(t *testing.T) { 48 | clientSet := fake.NewSimpleClientset() 49 | 50 | svcAccount := &corev1.ServiceAccount{ 51 | ObjectMeta: metav1.ObjectMeta{ 52 | Name: "my-service-account", 53 | }, 54 | ImagePullSecrets: []corev1.LocalObjectReference{ 55 | {Name: "my-sa-secret"}, 56 | }, 57 | } 58 | svcAccountSecretDockerConfigJson := []byte(`{"auths":{"my-sa-secret.registry.example.com":{"username":"my-sa-secret","password":"xxxxxxxxxxx","email":"jdoe@example.com","auth":"c3R...zE2"}}}`) 59 | svcAccountSecret := &corev1.Secret{ 60 | ObjectMeta: metav1.ObjectMeta{ 61 | Name: "my-sa-secret", 62 | }, 63 | Type: corev1.SecretTypeDockerConfigJson, 64 | Data: map[string][]byte{ 65 | corev1.DockerConfigJsonKey: svcAccountSecretDockerConfigJson, 66 | }, 67 | } 68 | podSecretDockerConfigJson := []byte(`{"auths":{"my-pod-secret.registry.example.com":{"username":"my-sa-secret","password":"xxxxxxxxxxx","email":"jdoe@example.com","auth":"c3R...zE2"}}}`) 69 | podSecret := &corev1.Secret{ 70 | ObjectMeta: metav1.ObjectMeta{ 71 | Name: "my-pod-secret", 72 | }, 73 | Type: corev1.SecretTypeDockerConfigJson, 74 | Data: map[string][]byte{ 75 | corev1.DockerConfigJsonKey: podSecretDockerConfigJson, 76 | }, 77 | } 78 | pod := &corev1.Pod{ 79 | ObjectMeta: metav1.ObjectMeta{ 80 | Namespace: "test-ns", 81 | Name: "my-pod", 82 | }, 83 | Spec: corev1.PodSpec{ 84 | ServiceAccountName: "my-service-account", 85 | ImagePullSecrets: []corev1.LocalObjectReference{ 86 | {Name: "my-pod-secret"}, 87 | }, 88 | }, 89 | } 90 | 91 | _, _ = clientSet.CoreV1().ServiceAccounts("test-ns").Create(context.TODO(), svcAccount, metav1.CreateOptions{}) 92 | _, _ = clientSet.CoreV1().Secrets("test-ns").Create(context.TODO(), svcAccountSecret, metav1.CreateOptions{}) 93 | _, _ = clientSet.CoreV1().Secrets("test-ns").Create(context.TODO(), podSecret, metav1.CreateOptions{}) 94 | 95 | provider := NewKubernetesImagePullSecretsProvider(clientSet) 96 | result, err := provider.GetImagePullSecrets(context.Background(), pod) 97 | 98 | assert.NoError(t, err) 99 | assert.NotNil(t, result) 100 | assert.Len(t, result.Secrets, 2) 101 | assert.Equal(t, svcAccountSecretDockerConfigJson, result.Secrets["my-sa-secret"]) 102 | assert.Equal(t, podSecretDockerConfigJson, result.Secrets["my-pod-secret"]) 103 | } 104 | 105 | // TestImagePullSecretsResult_WithDefault tests if authenticated private registries work 106 | func TestImagePullSecretsResult_WithDefault(t *testing.T) { 107 | fakeToken := []byte("token") 108 | fakeBase64Token := base64.StdEncoding.EncodeToString(fakeToken) 109 | fakeAccountIds := []string{"12345678912", "9876543210"} 110 | fakeRegions := []string{"us-east-1", "us-west-1"} 111 | fakeEcrDomains := []string{ 112 | fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com", fakeAccountIds[0], fakeRegions[0]), 113 | fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com", fakeAccountIds[1], fakeRegions[1]), 114 | } 115 | 116 | expected := &ImagePullSecretsResult{ 117 | Secrets: map[string][]byte{ 118 | "source-ecr-0": []byte("{\"auths\":{\"" + fakeEcrDomains[0] + "\":{\"auth\":\"" + fakeBase64Token + "\"}}}"), 119 | "source-ecr-1": []byte("{\"auths\":{\"" + fakeEcrDomains[1] + "\":{\"auth\":\"" + fakeBase64Token + "\"}}}"), 120 | }, 121 | Aggregate: []byte("{\"auths\":{\"" + fakeEcrDomains[0] + "\":{\"auth\":\"" + fakeBase64Token + "\"},\"" + fakeEcrDomains[1] + "\":{\"auth\":\"" + fakeBase64Token + "\"}}}"), 122 | } 123 | 124 | fakeRegistry1 := registry.NewDummyECRClient(fakeRegions[0], fakeAccountIds[0], "", config.ECROptions{}, fakeToken) 125 | fakeRegistry2 := registry.NewDummyECRClient(fakeRegions[1], fakeAccountIds[1], "", config.ECROptions{}, fakeToken) 126 | fakeRegistries := []registry.Client{fakeRegistry1, fakeRegistry2} 127 | 128 | r := NewImagePullSecretsResultWithDefaults(fakeRegistries) 129 | 130 | assert.Equal(t, r, expected) 131 | } 132 | 133 | // TestImagePullSecretsResult_Add tests if aggregation works 134 | func TestImagePullSecretsResult_Add(t *testing.T) { 135 | expected := &ImagePullSecretsResult{ 136 | Secrets: map[string][]byte{ 137 | "foo": []byte("{\"foo\":\"123\"}"), 138 | "bar": []byte("{\"bar\":\"456\"}"), 139 | }, 140 | Aggregate: []byte("{\"bar\":\"456\",\"foo\":\"123\"}"), 141 | } 142 | 143 | r := NewImagePullSecretsResult() 144 | r.Add("foo", []byte("{\"foo\":\"123\"}")) 145 | r.Add("bar", []byte("{\"bar\":\"456\"}")) 146 | 147 | assert.Equal(t, r, expected) 148 | } 149 | -------------------------------------------------------------------------------- /pkg/secrets/provider.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/estahn/k8s-image-swapper/pkg/registry" 7 | v1 "k8s.io/api/core/v1" 8 | ) 9 | 10 | type ImagePullSecretsProvider interface { 11 | GetImagePullSecrets(ctx context.Context, pod *v1.Pod) (*ImagePullSecretsResult, error) 12 | SetAuthenticatedRegistries(privateRegistries []registry.Client) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "fmt" 4 | 5 | type Registry int 6 | 7 | const ( 8 | RegistryUnknown = iota 9 | RegistryAWS 10 | RegistryGCP 11 | ) 12 | 13 | func (p Registry) String() string { 14 | return [...]string{"unknown", "aws", "gcp"}[p] 15 | } 16 | 17 | func ParseRegistry(p string) (Registry, error) { 18 | switch p { 19 | case Registry(RegistryAWS).String(): 20 | return RegistryAWS, nil 21 | case Registry(RegistryGCP).String(): 22 | return RegistryGCP, nil 23 | } 24 | return RegistryUnknown, fmt.Errorf("unknown target registry string: '%s', defaulting to unknown", p) 25 | } 26 | 27 | type ImageSwapPolicy int 28 | 29 | const ( 30 | ImageSwapPolicyAlways = iota 31 | ImageSwapPolicyExists 32 | ) 33 | 34 | func (p ImageSwapPolicy) String() string { 35 | return [...]string{"always", "exists"}[p] 36 | } 37 | 38 | func ParseImageSwapPolicy(p string) (ImageSwapPolicy, error) { 39 | switch p { 40 | case ImageSwapPolicy(ImageSwapPolicyAlways).String(): 41 | return ImageSwapPolicyAlways, nil 42 | case ImageSwapPolicy(ImageSwapPolicyExists).String(): 43 | return ImageSwapPolicyExists, nil 44 | } 45 | return ImageSwapPolicyExists, fmt.Errorf("unknown image swap policy string: '%s', defaulting to exists", p) 46 | } 47 | 48 | type ImageCopyPolicy int 49 | 50 | const ( 51 | ImageCopyPolicyDelayed = iota 52 | ImageCopyPolicyImmediate 53 | ImageCopyPolicyForce 54 | ImageCopyPolicyNone 55 | ) 56 | 57 | func (p ImageCopyPolicy) String() string { 58 | return [...]string{"delayed", "immediate", "force", "none"}[p] 59 | } 60 | 61 | func ParseImageCopyPolicy(p string) (ImageCopyPolicy, error) { 62 | switch p { 63 | case ImageCopyPolicy(ImageCopyPolicyDelayed).String(): 64 | return ImageCopyPolicyDelayed, nil 65 | case ImageCopyPolicy(ImageCopyPolicyImmediate).String(): 66 | return ImageCopyPolicyImmediate, nil 67 | case ImageCopyPolicy(ImageCopyPolicyForce).String(): 68 | return ImageCopyPolicyForce, nil 69 | case ImageCopyPolicy(ImageCopyPolicyNone).String(): 70 | return ImageCopyPolicyNone, nil 71 | } 72 | return ImageCopyPolicyDelayed, fmt.Errorf("unknown image copy policy string: '%s', defaulting to delayed", p) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/types/types_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "testing" 4 | 5 | func TestParseImageSwapPolicy(t *testing.T) { 6 | type args struct { 7 | p string 8 | } 9 | tests := []struct { 10 | name string 11 | args args 12 | want ImageSwapPolicy 13 | wantErr bool 14 | }{ 15 | { 16 | name: "always", 17 | args: args{p: "always"}, 18 | want: ImageSwapPolicyAlways, 19 | }, 20 | { 21 | name: "exists", 22 | args: args{p: "exists"}, 23 | want: ImageSwapPolicyExists, 24 | }, 25 | { 26 | name: "random-non-existent", 27 | args: args{p: "random-non-existent"}, 28 | want: ImageSwapPolicyExists, 29 | wantErr: true, 30 | }, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | got, err := ParseImageSwapPolicy(tt.args.p) 35 | if (err != nil) != tt.wantErr { 36 | t.Errorf("ParseImageSwapPolicy() error = %v, wantErr %v", err, tt.wantErr) 37 | return 38 | } 39 | if got != tt.want { 40 | t.Errorf("ParseImageSwapPolicy() got = %v, want %v", got, tt.want) 41 | } 42 | }) 43 | } 44 | } 45 | 46 | func TestParseImageCopyPolicy(t *testing.T) { 47 | type args struct { 48 | p string 49 | } 50 | tests := []struct { 51 | name string 52 | args args 53 | want ImageCopyPolicy 54 | wantErr bool 55 | }{ 56 | { 57 | name: "delayed", 58 | args: args{p: "delayed"}, 59 | want: ImageCopyPolicyDelayed, 60 | }, 61 | { 62 | name: "immediate", 63 | args: args{p: "immediate"}, 64 | want: ImageCopyPolicyImmediate, 65 | }, 66 | { 67 | name: "force", 68 | args: args{p: "force"}, 69 | want: ImageCopyPolicyForce, 70 | }, 71 | { 72 | name: "none", 73 | args: args{p: "none"}, 74 | want: ImageCopyPolicyNone, 75 | }, 76 | { 77 | name: "random-non-existent", 78 | args: args{p: "random-non-existent"}, 79 | want: ImageCopyPolicyDelayed, 80 | wantErr: true, 81 | }, 82 | } 83 | for _, tt := range tests { 84 | t.Run(tt.name, func(t *testing.T) { 85 | got, err := ParseImageCopyPolicy(tt.args.p) 86 | if (err != nil) != tt.wantErr { 87 | t.Errorf("ParseImageCopyPolicy() error = %v, wantErr %v", err, tt.wantErr) 88 | return 89 | } 90 | if got != tt.want { 91 | t.Errorf("ParseImageCopyPolicy() got = %v, want %v", got, tt.want) 92 | } 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/webhook/image_copier.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | 8 | "github.com/containers/image/v5/docker/reference" 9 | ctypes "github.com/containers/image/v5/types" 10 | "github.com/rs/zerolog/log" 11 | corev1 "k8s.io/api/core/v1" 12 | ) 13 | 14 | // struct representing a job of copying an image with its subcontext 15 | type ImageCopier struct { 16 | sourcePod *corev1.Pod 17 | sourceImageRef ctypes.ImageReference 18 | targetImageRef ctypes.ImageReference 19 | 20 | imagePullPolicy corev1.PullPolicy 21 | imageSwapper *ImageSwapper 22 | 23 | context context.Context 24 | cancelContext context.CancelFunc 25 | } 26 | 27 | type Task struct { 28 | function func() error 29 | description string 30 | } 31 | 32 | var ErrImageAlreadyPresent = errors.New("image already present in target registry") 33 | 34 | // replace the default context with a new one with a timeout 35 | func (ic *ImageCopier) withDeadline() *ImageCopier { 36 | imageCopierContext, imageCopierContextCancel := context.WithTimeout(ic.context, ic.imageSwapper.imageCopyDeadline) 37 | ic.context = imageCopierContext 38 | ic.cancelContext = imageCopierContextCancel 39 | return ic 40 | } 41 | 42 | // start the image copy job 43 | func (ic *ImageCopier) start() { 44 | if _, hasDeadline := ic.context.Deadline(); hasDeadline { 45 | defer ic.cancelContext() 46 | } 47 | 48 | // list of actions to execute in order to copy an image 49 | tasks := []*Task{ 50 | { 51 | function: ic.taskCheckImage, 52 | description: "checking image presence in target registry", 53 | }, 54 | { 55 | function: ic.taskCreateRepository, 56 | description: "creating a new repository in target registry", 57 | }, 58 | { 59 | function: ic.taskCopyImage, 60 | description: "copying image data to target repository", 61 | }, 62 | } 63 | 64 | for _, task := range tasks { 65 | err := ic.run(task.function) 66 | 67 | if err != nil { 68 | if errors.Is(err, context.DeadlineExceeded) { 69 | log.Ctx(ic.context).Err(err).Msg("timeout during image copy") 70 | } else if errors.Is(err, ErrImageAlreadyPresent) { 71 | log.Ctx(ic.context).Trace().Msgf("image copy aborted: %s", err.Error()) 72 | } else { 73 | log.Ctx(ic.context).Err(err).Msgf("image copy error while %s", task.description) 74 | } 75 | break 76 | } 77 | } 78 | } 79 | 80 | // run a task function and check for timeout 81 | func (ic *ImageCopier) run(taskFunc func() error) error { 82 | if err := ic.context.Err(); err != nil { 83 | return err 84 | } 85 | 86 | return taskFunc() 87 | } 88 | 89 | func (ic *ImageCopier) taskCheckImage() error { 90 | registryClient := ic.imageSwapper.registryClient 91 | 92 | imageAlreadyExists := registryClient.ImageExists(ic.context, ic.targetImageRef) && ic.imagePullPolicy != corev1.PullAlways 93 | 94 | if err := ic.context.Err(); err != nil { 95 | return err 96 | } else if imageAlreadyExists { 97 | return ErrImageAlreadyPresent 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func (ic *ImageCopier) taskCreateRepository() error { 104 | createRepoName := reference.TrimNamed(ic.sourceImageRef.DockerReference()).String() 105 | 106 | return ic.imageSwapper.registryClient.CreateRepository(ic.context, createRepoName) 107 | } 108 | 109 | func (ic *ImageCopier) taskCopyImage() error { 110 | ctx := ic.context 111 | // Retrieve secrets and auth credentials 112 | imagePullSecrets, err := ic.imageSwapper.imagePullSecretProvider.GetImagePullSecrets(ctx, ic.sourcePod) 113 | // not possible at the moment 114 | if err != nil { 115 | return err 116 | } 117 | 118 | authFile, err := imagePullSecrets.AuthFile() 119 | if err != nil { 120 | log.Ctx(ctx).Err(err).Msg("failed generating authFile") 121 | } 122 | 123 | defer func() { 124 | if err := os.RemoveAll(authFile.Name()); err != nil { 125 | log.Ctx(ctx).Err(err).Str("file", authFile.Name()).Msg("failed removing auth file") 126 | } 127 | }() 128 | 129 | if err := ctx.Err(); err != nil { 130 | return err 131 | } 132 | 133 | // Copy image 134 | // TODO: refactor to use structure instead of passing file name / string 135 | // 136 | // or transform registryClient creds into auth compatible form, e.g. 137 | // {"auths":{"aws_account_id.dkr.ecr.region.amazonaws.com":{"username":"AWS","password":"..." }}} 138 | return ic.imageSwapper.registryClient.CopyImage(ctx, ic.sourceImageRef, authFile.Name(), ic.targetImageRef, ic.imageSwapper.registryClient.Credentials()) 139 | } 140 | -------------------------------------------------------------------------------- /pkg/webhook/image_copier_test.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/service/ecr" 10 | "github.com/containers/image/v5/transports/alltransports" 11 | "github.com/estahn/k8s-image-swapper/pkg/registry" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/mock" 14 | corev1 "k8s.io/api/core/v1" 15 | ) 16 | 17 | func TestImageCopier_withDeadline(t *testing.T) { 18 | mutator := NewImageSwapperWithOpts( 19 | nil, 20 | ImageCopyDeadline(8*time.Second), 21 | ) 22 | 23 | imageSwapper, _ := mutator.(*ImageSwapper) 24 | 25 | imageCopier := &ImageCopier{ 26 | imageSwapper: imageSwapper, 27 | context: context.Background(), 28 | } 29 | 30 | imageCopier = imageCopier.withDeadline() 31 | deadline, hasDeadline := imageCopier.context.Deadline() 32 | 33 | // test that a deadline has been set 34 | assert.Equal(t, true, hasDeadline) 35 | 36 | // test that the deadline is future 37 | assert.GreaterOrEqual(t, deadline, time.Now()) 38 | 39 | // test that the context can be canceled 40 | assert.NotEqual(t, nil, imageCopier.context.Done()) 41 | 42 | imageCopier.cancelContext() 43 | 44 | _, ok := <-imageCopier.context.Done() 45 | // test that the Done channel is closed, meaning the context is canceled 46 | assert.Equal(t, false, ok) 47 | 48 | } 49 | 50 | func TestImageCopier_tasksTimeout(t *testing.T) { 51 | ecrClient := new(mockECRClient) 52 | ecrClient.On( 53 | "CreateRepositoryWithContext", 54 | mock.AnythingOfType("*context.timerCtx"), 55 | &ecr.CreateRepositoryInput{ 56 | ImageScanningConfiguration: &ecr.ImageScanningConfiguration{ 57 | ScanOnPush: aws.Bool(true), 58 | }, 59 | ImageTagMutability: aws.String("MUTABLE"), 60 | RepositoryName: aws.String("docker.io/library/init-container"), 61 | RegistryId: aws.String("123456789"), 62 | Tags: []*ecr.Tag{ 63 | { 64 | Key: aws.String("CreatedBy"), 65 | Value: aws.String("k8s-image-swapper"), 66 | }, 67 | }, 68 | }).Return(mock.Anything) 69 | 70 | registryClient, _ := registry.NewMockECRClient(ecrClient, "ap-southeast-2", "123456789.dkr.ecr.ap-southeast-2.amazonaws.com", "123456789", "arn:aws:iam::123456789:role/fakerole") 71 | 72 | // image swapper with an instant timeout for testing purpose 73 | mutator := NewImageSwapperWithOpts( 74 | registryClient, 75 | ImageCopyDeadline(0*time.Second), 76 | ) 77 | 78 | imageSwapper, _ := mutator.(*ImageSwapper) 79 | 80 | srcRef, _ := alltransports.ParseImageName("docker://library/init-container:latest") 81 | targetRef, _ := alltransports.ParseImageName("docker://123456789.dkr.ecr.ap-southeast-2.amazonaws.com/docker.io/library/init-container:latest") 82 | imageCopier := &ImageCopier{ 83 | imageSwapper: imageSwapper, 84 | context: context.Background(), 85 | sourceImageRef: srcRef, 86 | targetImageRef: targetRef, 87 | imagePullPolicy: corev1.PullAlways, 88 | sourcePod: &corev1.Pod{ 89 | Spec: corev1.PodSpec{ 90 | ServiceAccountName: "service-account", 91 | ImagePullSecrets: []corev1.LocalObjectReference{}, 92 | }, 93 | }, 94 | } 95 | imageCopier = imageCopier.withDeadline() 96 | 97 | // test that copy steps generate timeout errors 98 | var timeoutError error 99 | 100 | timeoutError = imageCopier.run(imageCopier.taskCheckImage) 101 | assert.Equal(t, context.DeadlineExceeded, timeoutError) 102 | 103 | timeoutError = imageCopier.run(imageCopier.taskCreateRepository) 104 | assert.Equal(t, context.DeadlineExceeded, timeoutError) 105 | 106 | timeoutError = imageCopier.run(imageCopier.taskCopyImage) 107 | assert.Equal(t, context.DeadlineExceeded, timeoutError) 108 | 109 | timeoutError = imageCopier.taskCheckImage() 110 | assert.Equal(t, context.DeadlineExceeded, timeoutError) 111 | 112 | timeoutError = imageCopier.taskCreateRepository() 113 | assert.Equal(t, context.DeadlineExceeded, timeoutError) 114 | 115 | timeoutError = imageCopier.taskCopyImage() 116 | assert.Equal(t, context.DeadlineExceeded, timeoutError) 117 | } 118 | -------------------------------------------------------------------------------- /pkg/webhook/image_swapper.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/alitto/pond" 10 | "github.com/containers/image/v5/docker/reference" 11 | "github.com/containers/image/v5/transports/alltransports" 12 | ctypes "github.com/containers/image/v5/types" 13 | "github.com/estahn/k8s-image-swapper/pkg/config" 14 | "github.com/estahn/k8s-image-swapper/pkg/registry" 15 | "github.com/estahn/k8s-image-swapper/pkg/secrets" 16 | types "github.com/estahn/k8s-image-swapper/pkg/types" 17 | jmespath "github.com/jmespath/go-jmespath" 18 | "github.com/rs/zerolog/log" 19 | kwhmodel "github.com/slok/kubewebhook/v2/pkg/model" 20 | "github.com/slok/kubewebhook/v2/pkg/webhook" 21 | kwhmutating "github.com/slok/kubewebhook/v2/pkg/webhook/mutating" 22 | corev1 "k8s.io/api/core/v1" 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | ) 25 | 26 | // Option represents an option that can be passed when instantiating the image swapper to customize it 27 | type Option func(*ImageSwapper) 28 | 29 | // ImagePullSecretsProvider allows to pass a provider reading out Kubernetes secrets 30 | func ImagePullSecretsProvider(provider secrets.ImagePullSecretsProvider) Option { 31 | return func(swapper *ImageSwapper) { 32 | swapper.imagePullSecretProvider = provider 33 | } 34 | } 35 | 36 | // Filters allows to pass JMESPathFilter to select the images to be swapped 37 | func Filters(filters []config.JMESPathFilter) Option { 38 | return func(swapper *ImageSwapper) { 39 | swapper.filters = filters 40 | } 41 | } 42 | 43 | // ImageSwapPolicy allows to pass the ImageSwapPolicy option 44 | func ImageSwapPolicy(policy types.ImageSwapPolicy) Option { 45 | return func(swapper *ImageSwapper) { 46 | swapper.imageSwapPolicy = policy 47 | } 48 | } 49 | 50 | // ImageCopyPolicy allows to pass the ImageCopyPolicy option 51 | func ImageCopyPolicy(policy types.ImageCopyPolicy) Option { 52 | return func(swapper *ImageSwapper) { 53 | swapper.imageCopyPolicy = policy 54 | } 55 | } 56 | 57 | // ImageCopyDeadline allows to pass the ImageCopyPolicy option 58 | func ImageCopyDeadline(deadline time.Duration) Option { 59 | return func(swapper *ImageSwapper) { 60 | swapper.imageCopyDeadline = deadline 61 | } 62 | } 63 | 64 | // Copier allows to pass the copier option 65 | func Copier(pool *pond.WorkerPool) Option { 66 | return func(swapper *ImageSwapper) { 67 | swapper.copier = pool 68 | } 69 | } 70 | 71 | // ImageSwapper is a mutator that will download images and change the image name. 72 | type ImageSwapper struct { 73 | registryClient registry.Client 74 | imagePullSecretProvider secrets.ImagePullSecretsProvider 75 | 76 | // filters defines a list of expressions to remove objects that should not be processed, 77 | // by default all objects will be processed 78 | filters []config.JMESPathFilter 79 | 80 | // copier manages the jobs copying the images to the target registry 81 | copier *pond.WorkerPool 82 | imageCopyDeadline time.Duration 83 | 84 | imageSwapPolicy types.ImageSwapPolicy 85 | imageCopyPolicy types.ImageCopyPolicy 86 | } 87 | 88 | // NewImageSwapper returns a new ImageSwapper initialized. 89 | func NewImageSwapper(registryClient registry.Client, imagePullSecretProvider secrets.ImagePullSecretsProvider, filters []config.JMESPathFilter, imageSwapPolicy types.ImageSwapPolicy, imageCopyPolicy types.ImageCopyPolicy, imageCopyDeadline time.Duration) kwhmutating.Mutator { 90 | return &ImageSwapper{ 91 | registryClient: registryClient, 92 | imagePullSecretProvider: imagePullSecretProvider, 93 | filters: filters, 94 | copier: pond.New(100, 1000), 95 | imageSwapPolicy: imageSwapPolicy, 96 | imageCopyPolicy: imageCopyPolicy, 97 | imageCopyDeadline: imageCopyDeadline, 98 | } 99 | } 100 | 101 | // NewImageSwapperWithOpts returns a configured ImageSwapper instance 102 | func NewImageSwapperWithOpts(registryClient registry.Client, opts ...Option) kwhmutating.Mutator { 103 | swapper := &ImageSwapper{ 104 | registryClient: registryClient, 105 | imagePullSecretProvider: secrets.NewDummyImagePullSecretsProvider(), 106 | filters: []config.JMESPathFilter{}, 107 | imageSwapPolicy: types.ImageSwapPolicyExists, 108 | imageCopyPolicy: types.ImageCopyPolicyDelayed, 109 | } 110 | 111 | for _, opt := range opts { 112 | opt(swapper) 113 | } 114 | 115 | // Initialise worker pool if not configured 116 | if swapper.copier == nil { 117 | swapper.copier = pond.New(100, 1000) 118 | } 119 | 120 | return swapper 121 | } 122 | 123 | func NewImageSwapperWebhookWithOpts(registryClient registry.Client, opts ...Option) (webhook.Webhook, error) { 124 | imageSwapper := NewImageSwapperWithOpts(registryClient, opts...) 125 | mt := kwhmutating.MutatorFunc(imageSwapper.Mutate) 126 | mcfg := kwhmutating.WebhookConfig{ 127 | ID: "k8s-image-swapper", 128 | Obj: &corev1.Pod{}, 129 | Mutator: mt, 130 | } 131 | 132 | return kwhmutating.NewWebhook(mcfg) 133 | } 134 | 135 | func NewImageSwapperWebhook(registryClient registry.Client, imagePullSecretProvider secrets.ImagePullSecretsProvider, filters []config.JMESPathFilter, imageSwapPolicy types.ImageSwapPolicy, imageCopyPolicy types.ImageCopyPolicy, imageCopyDeadline time.Duration) (webhook.Webhook, error) { 136 | imageSwapper := NewImageSwapper(registryClient, imagePullSecretProvider, filters, imageSwapPolicy, imageCopyPolicy, imageCopyDeadline) 137 | mt := kwhmutating.MutatorFunc(imageSwapper.Mutate) 138 | mcfg := kwhmutating.WebhookConfig{ 139 | ID: "k8s-image-swapper", 140 | Obj: &corev1.Pod{}, 141 | Mutator: mt, 142 | } 143 | 144 | return kwhmutating.NewWebhook(mcfg) 145 | } 146 | 147 | // imageNamesWithDigestOrTag strips the tag from ambiguous image references that have a digest as well (e.g. `image:tag@sha256:123...`). 148 | // Such image references are supported by docker but, due to their ambiguity, 149 | // explicitly not by containers/image. 150 | func imageNamesWithDigestOrTag(imageName string) (string, error) { 151 | ref, err := reference.ParseNormalizedNamed(imageName) 152 | if err != nil { 153 | return "", err 154 | } 155 | _, isTagged := ref.(reference.NamedTagged) 156 | canonical, isDigested := ref.(reference.Canonical) 157 | if isTagged && isDigested { 158 | canonical, err = reference.WithDigest(reference.TrimNamed(ref), canonical.Digest()) 159 | if err != nil { 160 | return "", err 161 | } 162 | imageName = canonical.String() 163 | } 164 | return imageName, nil 165 | } 166 | 167 | // Mutate replaces the image ref. Satisfies mutating.Mutator interface. 168 | func (p *ImageSwapper) Mutate(ctx context.Context, ar *kwhmodel.AdmissionReview, obj metav1.Object) (*kwhmutating.MutatorResult, error) { 169 | pod, ok := obj.(*corev1.Pod) 170 | if !ok { 171 | return &kwhmutating.MutatorResult{}, nil 172 | } 173 | 174 | logger := log.With(). 175 | Str("uid", string(ar.ID)). 176 | Str("kind", ar.RequestGVK.String()). 177 | Str("namespace", ar.Namespace). 178 | Str("name", pod.Name). 179 | Logger() 180 | 181 | lctx := logger.WithContext(context.Background()) 182 | 183 | containerSets := []*[]corev1.Container{&pod.Spec.Containers, &pod.Spec.InitContainers} 184 | for _, containerSet := range containerSets { 185 | containers := *containerSet 186 | for i, container := range containers { 187 | normalizedName, err := imageNamesWithDigestOrTag(container.Image) 188 | if err != nil { 189 | log.Ctx(lctx).Warn().Msgf("unable to normalize source name %s: %v", container.Image, err) 190 | continue 191 | } 192 | 193 | srcRef, err := alltransports.ParseImageName("docker://" + normalizedName) 194 | if err != nil { 195 | log.Ctx(lctx).Warn().Msgf("invalid source name %s: %v", normalizedName, err) 196 | continue 197 | } 198 | 199 | // skip if the source originates from the target registry 200 | if p.registryClient.IsOrigin(srcRef) { 201 | log.Ctx(lctx).Debug().Str("registry", srcRef.DockerReference().String()).Msg("skip due to source and target being the same registry") 202 | continue 203 | } 204 | 205 | filterCtx := NewFilterContext(*ar, pod, container) 206 | if filterMatch(filterCtx, p.filters) { 207 | log.Ctx(lctx).Debug().Msg("skip due to filter condition") 208 | continue 209 | } 210 | 211 | targetRef := p.targetRef(srcRef) 212 | targetImage := targetRef.DockerReference().String() 213 | 214 | imageCopierLogger := logger.With(). 215 | Str("source-image", srcRef.DockerReference().String()). 216 | Str("target-image", targetImage). 217 | Logger() 218 | 219 | imageCopierContext := imageCopierLogger.WithContext(lctx) 220 | // create an object responsible for the image copy 221 | imageCopier := ImageCopier{ 222 | sourcePod: pod, 223 | sourceImageRef: srcRef, 224 | targetImageRef: targetRef, 225 | imagePullPolicy: container.ImagePullPolicy, 226 | imageSwapper: p, 227 | context: imageCopierContext, 228 | } 229 | 230 | // imageCopyPolicy 231 | switch p.imageCopyPolicy { 232 | case types.ImageCopyPolicyDelayed: 233 | p.copier.Submit(imageCopier.start) 234 | case types.ImageCopyPolicyImmediate: 235 | p.copier.SubmitAndWait(imageCopier.withDeadline().start) 236 | case types.ImageCopyPolicyForce: 237 | imageCopier.withDeadline().start() 238 | case types.ImageCopyPolicyNone: 239 | // do not copy image 240 | default: 241 | panic("unknown imageCopyPolicy") 242 | } 243 | 244 | // imageSwapPolicy 245 | switch p.imageSwapPolicy { 246 | case types.ImageSwapPolicyAlways: 247 | log.Ctx(lctx).Debug().Str("image", targetImage).Msg("set new container image") 248 | containers[i].Image = targetImage 249 | case types.ImageSwapPolicyExists: 250 | if p.registryClient.ImageExists(lctx, targetRef) { 251 | log.Ctx(lctx).Debug().Str("image", targetImage).Msg("set new container image") 252 | containers[i].Image = targetImage 253 | } else { 254 | log.Ctx(lctx).Debug().Str("image", targetImage).Msg("container image not found in target registry, not swapping") 255 | } 256 | default: 257 | panic("unknown imageSwapPolicy") 258 | } 259 | } 260 | } 261 | 262 | return &kwhmutating.MutatorResult{MutatedObject: pod}, nil 263 | } 264 | 265 | // filterMatch returns true if one of the filters matches the context 266 | func filterMatch(ctx FilterContext, filters []config.JMESPathFilter) bool { 267 | // Simplify FilterContext to be easier searchable by marshaling it to JSON and back to an interface 268 | var filterContext interface{} 269 | jsonBlob, err := json.Marshal(ctx) 270 | if err != nil { 271 | log.Err(err).Msg("could not marshal filter context") 272 | return false 273 | } 274 | 275 | err = json.Unmarshal(jsonBlob, &filterContext) 276 | if err != nil { 277 | log.Err(err).Msg("could not unmarshal json blob") 278 | return false 279 | } 280 | 281 | log.Debug().Interface("object", filterContext).Msg("generated filter context") 282 | 283 | for idx, filter := range filters { 284 | results, err := jmespath.Search(filter.JMESPath, filterContext) 285 | log.Debug().Str("filter", filter.JMESPath).Interface("results", results).Msg("jmespath search results") 286 | 287 | if err != nil { 288 | log.Err(err).Str("filter", filter.JMESPath).Msgf("Filter (idx %v) could not be evaluated.", idx) 289 | return false 290 | } 291 | 292 | switch results.(type) { 293 | case bool: 294 | if results == true { 295 | return true 296 | } 297 | default: 298 | log.Warn().Str("filter", filter.JMESPath).Msg("filter does not return a bool value") 299 | } 300 | } 301 | 302 | return false 303 | } 304 | 305 | // targetName returns the reference in the target repository 306 | func (p *ImageSwapper) targetRef(srcRef ctypes.ImageReference) ctypes.ImageReference { 307 | targetImage := fmt.Sprintf("%s/%s", p.registryClient.Endpoint(), srcRef.DockerReference().String()) 308 | 309 | ref, err := alltransports.ParseImageName("docker://" + targetImage) 310 | if err != nil { 311 | log.Warn().Msgf("invalid target name %s: %v", targetImage, err) 312 | } 313 | 314 | return ref 315 | } 316 | 317 | // FilterContext is being used by JMESPath to search and match 318 | type FilterContext struct { 319 | // Obj contains the object submitted to the webhook (currently only pods) 320 | Obj metav1.Object `json:"obj,omitempty"` 321 | 322 | // Container contains the currently processed container 323 | Container corev1.Container `json:"container,omitempty"` 324 | } 325 | 326 | func NewFilterContext(request kwhmodel.AdmissionReview, obj metav1.Object, container corev1.Container) FilterContext { 327 | if obj.GetNamespace() == "" { 328 | obj.SetNamespace(request.Namespace) 329 | } 330 | 331 | return FilterContext{Obj: obj, Container: container} 332 | } 333 | -------------------------------------------------------------------------------- /pkg/webhook/image_swapper_test.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "testing" 8 | 9 | "github.com/alitto/pond" 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/aws/request" 12 | "github.com/aws/aws-sdk-go/service/ecr" 13 | "github.com/aws/aws-sdk-go/service/ecr/ecriface" 14 | "github.com/estahn/k8s-image-swapper/pkg/config" 15 | "github.com/estahn/k8s-image-swapper/pkg/registry" 16 | "github.com/estahn/k8s-image-swapper/pkg/secrets" 17 | "github.com/estahn/k8s-image-swapper/pkg/types" 18 | "github.com/slok/kubewebhook/v2/pkg/model" 19 | "github.com/stretchr/testify/assert" 20 | "github.com/stretchr/testify/mock" 21 | admissionv1 "k8s.io/api/admission/v1" 22 | corev1 "k8s.io/api/core/v1" 23 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 | "k8s.io/client-go/kubernetes/fake" 25 | ) 26 | 27 | //func TestImageSwapperMutator(t *testing.T) { 28 | // tests := []struct { 29 | // name string 30 | // pod *corev1.Pod 31 | // labels map[string]string 32 | // expPod *corev1.Pod 33 | // expErr bool 34 | // }{ 35 | // { 36 | // name: "Prefix docker hub images with host docker.io.", 37 | // pod: &corev1.Pod{ 38 | // Spec: corev1.PodSpec{ 39 | // Containers: []corev1.Container{ 40 | // { 41 | // Image: "nginx:latest", 42 | // }, 43 | // }, 44 | // }, 45 | // }, 46 | // expPod: &corev1.Pod{ 47 | // Spec: corev1.PodSpec{ 48 | // Containers: []corev1.Container{ 49 | // { 50 | // Image: "foobar.com/docker.io/nginx:latest", 51 | // }, 52 | // }, 53 | // }, 54 | // }, 55 | // }, 56 | // { 57 | // name: "Don't mutate if targetRegistry host is target targetRegistry.", 58 | // pod: &corev1.Pod{ 59 | // Spec: corev1.PodSpec{ 60 | // Containers: []corev1.Container{ 61 | // { 62 | // Image: "foobar.com/docker.io/nginx:latest", 63 | // }, 64 | // }, 65 | // }, 66 | // }, 67 | // expPod: &corev1.Pod{ 68 | // Spec: corev1.PodSpec{ 69 | // Containers: []corev1.Container{ 70 | // { 71 | // Image: "foobar.com/docker.io/nginx:latest", 72 | // }, 73 | // }, 74 | // }, 75 | // }, 76 | // }, 77 | // } 78 | // 79 | // for _, test := range tests { 80 | // t.Run(test.name, func(t *testing.T) { 81 | // assert := assert.New(t) 82 | // 83 | // pl := NewImageSwapper("foobar.com") 84 | // 85 | // gotPod := test.pod 86 | // _, err := pl.Mutate(context.TODO(), gotPod) 87 | // 88 | // if test.expErr { 89 | // assert.Error(err) 90 | // } else if assert.NoError(err) { 91 | // assert.Equal(test.expPod, gotPod) 92 | // } 93 | // }) 94 | // } 95 | // 96 | //} 97 | // 98 | //func TestAnnotatePodMutator2(t *testing.T) { 99 | // tests := []struct { 100 | // name string 101 | // pod *corev1.Pod 102 | // labels map[string]string 103 | // expPod *corev1.Pod 104 | // expErr bool 105 | // }{ 106 | // { 107 | // name: "Mutating a pod without labels should set the labels correctly.", 108 | // pod: &corev1.Pod{ 109 | // ObjectMeta: metav1.ObjectMeta{ 110 | // Name: "test", 111 | // }, 112 | // }, 113 | // labels: map[string]string{"bruce": "wayne", "peter": "parker"}, 114 | // expPod: &corev1.Pod{ 115 | // ObjectMeta: metav1.ObjectMeta{ 116 | // Name: "test", 117 | // Labels: map[string]string{"bruce": "wayne", "peter": "parker"}, 118 | // }, 119 | // }, 120 | // }, 121 | // { 122 | // name: "Mutating a pod with labels should aggregate and replace the labels with the existing ones.", 123 | // pod: &corev1.Pod{ 124 | // ObjectMeta: metav1.ObjectMeta{ 125 | // Name: "test", 126 | // Labels: map[string]string{"bruce": "banner", "tony": "stark"}, 127 | // }, 128 | // }, 129 | // labels: map[string]string{"bruce": "wayne", "peter": "parker"}, 130 | // expPod: &corev1.Pod{ 131 | // ObjectMeta: metav1.ObjectMeta{ 132 | // Name: "test", 133 | // Labels: map[string]string{"bruce": "wayne", "peter": "parker", "tony": "stark"}, 134 | // }, 135 | // }, 136 | // }, 137 | // } 138 | // 139 | // for _, test := range tests { 140 | // t.Run(test.name, func(t *testing.T) { 141 | // assert := assert.New(t) 142 | // 143 | // pl := mutatortesting.NewPodLabeler(test.labels) 144 | // gotPod := test.pod 145 | // _, err := pl.Mutate(context.TODO(), gotPod) 146 | // 147 | // if test.expErr { 148 | // assert.Error(err) 149 | // } else if assert.NoError(err) { 150 | // // Check the expected pod. 151 | // assert.Equal(test.expPod, gotPod) 152 | // } 153 | // }) 154 | // } 155 | // 156 | //} 157 | 158 | //func TestRegistryHost(t *testing.T) { 159 | // assert.Equal(t, "", registryDomain("nginx:latest")) 160 | // assert.Equal(t, "docker.io", registryDomain("docker.io/nginx:latest")) 161 | //} 162 | 163 | func TestFilterMatch(t *testing.T) { 164 | filterContext := FilterContext{ 165 | Obj: &corev1.Pod{ 166 | ObjectMeta: metav1.ObjectMeta{ 167 | Namespace: "kube-system", 168 | }, 169 | Spec: corev1.PodSpec{ 170 | Containers: []corev1.Container{ 171 | { 172 | Name: "nginx", 173 | Image: "nginx:latest", 174 | }, 175 | }, 176 | }, 177 | }, 178 | Container: corev1.Container{ 179 | Name: "nginx", 180 | Image: "nginx:latest", 181 | }, 182 | } 183 | 184 | assert.True(t, filterMatch(filterContext, []config.JMESPathFilter{{JMESPath: "obj.metadata.namespace == 'kube-system'"}})) 185 | assert.False(t, filterMatch(filterContext, []config.JMESPathFilter{{JMESPath: "obj.metadata.namespace != 'kube-system'"}})) 186 | assert.False(t, filterMatch(filterContext, []config.JMESPathFilter{{JMESPath: "obj"}})) 187 | assert.True(t, filterMatch(filterContext, []config.JMESPathFilter{{JMESPath: "container.name == 'nginx'"}})) 188 | // false syntax test 189 | assert.False(t, filterMatch(filterContext, []config.JMESPathFilter{{JMESPath: "."}})) 190 | // non-boolean value 191 | assert.False(t, filterMatch(filterContext, []config.JMESPathFilter{{JMESPath: "obj"}})) 192 | assert.False(t, filterMatch(filterContext, []config.JMESPathFilter{{JMESPath: "contains(container.image, '.dkr.ecr.') && contains(container.image, '.amazonaws.com')"}})) 193 | } 194 | 195 | type mockECRClient struct { 196 | mock.Mock 197 | ecriface.ECRAPI 198 | } 199 | 200 | func (m *mockECRClient) CreateRepositoryWithContext(ctx context.Context, createRepositoryInput *ecr.CreateRepositoryInput, opts ...request.Option) (*ecr.CreateRepositoryOutput, error) { 201 | if ctx.Err() != nil { 202 | return nil, ctx.Err() 203 | } 204 | 205 | m.Called(ctx, createRepositoryInput) 206 | return &ecr.CreateRepositoryOutput{}, nil 207 | } 208 | 209 | func TestHelperProcess(t *testing.T) { 210 | if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { 211 | return 212 | } 213 | os.Exit(0) 214 | } 215 | 216 | func readAdmissionReviewFromFile(filename string) (*admissionv1.AdmissionReview, error) { 217 | data, err := os.ReadFile("../../test/requests/" + filename) 218 | if err != nil { 219 | return nil, err 220 | } 221 | 222 | ar := &admissionv1.AdmissionReview{} 223 | if err := json.Unmarshal(data, ar); err != nil { 224 | return nil, err 225 | } 226 | 227 | return ar, nil 228 | } 229 | 230 | func TestImageSwapper_Mutate(t *testing.T) { 231 | expectedRepositories := []string{ 232 | "docker.io/library/init-container", 233 | "docker.io/library/nginx", 234 | "k8s.gcr.io/ingress-nginx/controller", 235 | "us-central1-docker.pkg.dev/gcp-project-123/main/k8s.gcr.io/ingress-nginx/controller", 236 | } 237 | 238 | ecrClient := new(mockECRClient) 239 | 240 | for _, expectedRepository := range expectedRepositories { 241 | ecrClient.On( 242 | "CreateRepositoryWithContext", 243 | mock.AnythingOfType("*context.valueCtx"), 244 | &ecr.CreateRepositoryInput{ 245 | ImageScanningConfiguration: &ecr.ImageScanningConfiguration{ 246 | ScanOnPush: aws.Bool(true), 247 | }, 248 | EncryptionConfiguration: &ecr.EncryptionConfiguration{ 249 | EncryptionType: aws.String("AES256"), 250 | }, 251 | ImageTagMutability: aws.String("MUTABLE"), 252 | RepositoryName: aws.String(expectedRepository), 253 | RegistryId: aws.String("123456789"), 254 | Tags: []*ecr.Tag{ 255 | { 256 | Key: aws.String("CreatedBy"), 257 | Value: aws.String("k8s-image-swapper"), 258 | }, 259 | { 260 | Key: aws.String("AnotherTag"), 261 | Value: aws.String("another-tag"), 262 | }, 263 | }, 264 | }).Return(mock.Anything) 265 | } 266 | 267 | registryClient, _ := registry.NewMockECRClient(ecrClient, "ap-southeast-2", "123456789.dkr.ecr.ap-southeast-2.amazonaws.com", "123456789", "arn:aws:iam::123456789:role/fakerole") 268 | 269 | admissionReview, _ := readAdmissionReviewFromFile("admissionreview-simple.json") 270 | admissionReviewModel := model.NewAdmissionReviewV1(admissionReview) 271 | 272 | copier := pond.New(1, 1) 273 | // TODO: test types.ImageSwapPolicyExists 274 | wh, err := NewImageSwapperWebhookWithOpts( 275 | registryClient, 276 | Copier(copier), 277 | ImageSwapPolicy(types.ImageSwapPolicyAlways), 278 | ) 279 | 280 | assert.NoError(t, err, "NewImageSwapperWebhookWithOpts executed without errors") 281 | 282 | resp, err := wh.Review(context.Background(), admissionReviewModel) 283 | 284 | // TODO: think about moving "expected" into a file, e.g. admissionreview-simple-response-ecr.json 285 | // container with name "skip-test-gar" should be skipped, hence there is no "replace" operation for it 286 | expected := `[ 287 | {"op":"replace","path":"/spec/initContainers/0/image","value":"123456789.dkr.ecr.ap-southeast-2.amazonaws.com/docker.io/library/init-container:latest"}, 288 | {"op":"replace","path":"/spec/containers/0/image","value":"123456789.dkr.ecr.ap-southeast-2.amazonaws.com/docker.io/library/nginx:latest"}, 289 | {"op":"replace","path":"/spec/containers/1/image","value":"123456789.dkr.ecr.ap-southeast-2.amazonaws.com/k8s.gcr.io/ingress-nginx/controller@sha256:9bba603b99bf25f6d117cf1235b6598c16033ad027b143c90fa5b3cc583c5713"}, 290 | {"op":"replace","path":"/spec/containers/3/image","value":"123456789.dkr.ecr.ap-southeast-2.amazonaws.com/us-central1-docker.pkg.dev/gcp-project-123/main/k8s.gcr.io/ingress-nginx/controller@sha256:9bba603b99bf25f6d117cf1235b6598c16033ad027b143c90fa5b3cc583c5713"} 291 | ]` 292 | 293 | assert.JSONEq(t, expected, string(resp.(*model.MutatingAdmissionResponse).JSONPatchPatch)) 294 | assert.Nil(t, resp.(*model.MutatingAdmissionResponse).Warnings) 295 | assert.NoError(t, err, "Webhook executed without errors") 296 | 297 | // Ensure the worker pool is empty before asserting ecrClient 298 | copier.StopAndWait() 299 | 300 | ecrClient.AssertExpectations(t) 301 | } 302 | 303 | // TestImageSwapper_MutateWithImagePullSecrets tests mutating with imagePullSecret support 304 | func TestImageSwapper_MutateWithImagePullSecrets(t *testing.T) { 305 | ecrClient := new(mockECRClient) 306 | ecrClient.On( 307 | "CreateRepositoryWithContext", 308 | mock.AnythingOfType("*context.valueCtx"), 309 | &ecr.CreateRepositoryInput{ 310 | ImageScanningConfiguration: &ecr.ImageScanningConfiguration{ 311 | ScanOnPush: aws.Bool(true), 312 | }, 313 | EncryptionConfiguration: &ecr.EncryptionConfiguration{ 314 | EncryptionType: aws.String("AES256"), 315 | }, 316 | ImageTagMutability: aws.String("MUTABLE"), 317 | RegistryId: aws.String("123456789"), 318 | RepositoryName: aws.String("docker.io/library/nginx"), 319 | Tags: []*ecr.Tag{ 320 | { 321 | Key: aws.String("CreatedBy"), 322 | Value: aws.String("k8s-image-swapper"), 323 | }, 324 | { 325 | Key: aws.String("AnotherTag"), 326 | Value: aws.String("another-tag"), 327 | }, 328 | }, 329 | }).Return(mock.Anything) 330 | 331 | registryClient, _ := registry.NewMockECRClient(ecrClient, "ap-southeast-2", "123456789.dkr.ecr.ap-southeast-2.amazonaws.com", "123456789", "arn:aws:iam::123456789:role/fakerole") 332 | 333 | admissionReview, _ := readAdmissionReviewFromFile("admissionreview-imagepullsecrets.json") 334 | admissionReviewModel := model.NewAdmissionReviewV1(admissionReview) 335 | 336 | clientSet := fake.NewSimpleClientset() 337 | 338 | svcAccount := &corev1.ServiceAccount{ 339 | ObjectMeta: metav1.ObjectMeta{ 340 | Name: "my-service-account", 341 | }, 342 | ImagePullSecrets: []corev1.LocalObjectReference{ 343 | {Name: "my-sa-secret"}, 344 | }, 345 | } 346 | svcAccountSecretDockerConfigJson := []byte(`{"auths":{"my-sa-secret.registry.example.com":{"username":"my-sa-secret","password":"xxxxxxxxxxx","email":"jdoe@example.com","auth":"c3R...zE2"}}}`) 347 | svcAccountSecret := &corev1.Secret{ 348 | ObjectMeta: metav1.ObjectMeta{ 349 | Name: "my-sa-secret", 350 | }, 351 | Type: corev1.SecretTypeDockerConfigJson, 352 | Data: map[string][]byte{ 353 | corev1.DockerConfigJsonKey: svcAccountSecretDockerConfigJson, 354 | }, 355 | } 356 | podSecretDockerConfigJson := []byte(`{"auths":{"my-pod-secret.registry.example.com":{"username":"my-sa-secret","password":"xxxxxxxxxxx","email":"jdoe@example.com","auth":"c3R...zE2"}}}`) 357 | podSecret := &corev1.Secret{ 358 | ObjectMeta: metav1.ObjectMeta{ 359 | Name: "my-pod-secret", 360 | }, 361 | Type: corev1.SecretTypeDockerConfigJson, 362 | Data: map[string][]byte{ 363 | corev1.DockerConfigJsonKey: podSecretDockerConfigJson, 364 | }, 365 | } 366 | 367 | _, _ = clientSet.CoreV1().ServiceAccounts("test-ns").Create(context.Background(), svcAccount, metav1.CreateOptions{}) 368 | _, _ = clientSet.CoreV1().Secrets("test-ns").Create(context.Background(), svcAccountSecret, metav1.CreateOptions{}) 369 | _, _ = clientSet.CoreV1().Secrets("test-ns").Create(context.Background(), podSecret, metav1.CreateOptions{}) 370 | 371 | provider := secrets.NewKubernetesImagePullSecretsProvider(clientSet) 372 | 373 | copier := pond.New(1, 1) 374 | // TODO: test types.ImageSwapPolicyExists 375 | wh, err := NewImageSwapperWebhookWithOpts( 376 | registryClient, 377 | ImagePullSecretsProvider(provider), 378 | Copier(copier), 379 | ImageSwapPolicy(types.ImageSwapPolicyAlways), 380 | ) 381 | 382 | assert.NoError(t, err, "NewImageSwapperWebhookWithOpts executed without errors") 383 | 384 | resp, err := wh.Review(context.Background(), admissionReviewModel) 385 | 386 | assert.JSONEq(t, "[{\"op\":\"replace\",\"path\":\"/spec/containers/0/image\",\"value\":\"123456789.dkr.ecr.ap-southeast-2.amazonaws.com/docker.io/library/nginx:latest\"}]", string(resp.(*model.MutatingAdmissionResponse).JSONPatchPatch)) 387 | assert.Nil(t, resp.(*model.MutatingAdmissionResponse).Warnings) 388 | assert.NoError(t, err, "Webhook executed without errors") 389 | 390 | // Ensure the worker pool is empty before asserting ecrClient 391 | copier.StopAndWait() 392 | 393 | ecrClient.AssertExpectations(t) 394 | } 395 | 396 | func TestImageSwapper_GAR_Mutate(t *testing.T) { 397 | registryClient, _ := registry.NewMockGARClient(nil, "us-central1-docker.pkg.dev/gcp-project-123/main") 398 | 399 | admissionReview, _ := readAdmissionReviewFromFile("admissionreview-simple.json") 400 | admissionReviewModel := model.NewAdmissionReviewV1(admissionReview) 401 | 402 | copier := pond.New(1, 1) 403 | // TODO: test types.ImageSwapPolicyExists 404 | wh, err := NewImageSwapperWebhookWithOpts( 405 | registryClient, 406 | Copier(copier), 407 | ImageSwapPolicy(types.ImageSwapPolicyAlways), 408 | ) 409 | 410 | assert.NoError(t, err, "NewImageSwapperWebhookWithOpts executed without errors") 411 | 412 | resp, err := wh.Review(context.TODO(), admissionReviewModel) 413 | 414 | // container with name "skip-test-gar" should be skipped, hence there is no "replace" operation for it 415 | expected := `[ 416 | {"op":"replace","path":"/spec/initContainers/0/image","value":"us-central1-docker.pkg.dev/gcp-project-123/main/docker.io/library/init-container:latest"}, 417 | {"op":"replace","path":"/spec/containers/0/image","value":"us-central1-docker.pkg.dev/gcp-project-123/main/docker.io/library/nginx:latest"}, 418 | {"op":"replace","path":"/spec/containers/1/image","value":"us-central1-docker.pkg.dev/gcp-project-123/main/k8s.gcr.io/ingress-nginx/controller@sha256:9bba603b99bf25f6d117cf1235b6598c16033ad027b143c90fa5b3cc583c5713"}, 419 | {"op":"replace","path":"/spec/containers/2/image","value":"us-central1-docker.pkg.dev/gcp-project-123/main/123456789.dkr.ecr.ap-southeast-2.amazonaws.com/k8s.gcr.io/ingress-nginx/controller@sha256:9bba603b99bf25f6d117cf1235b6598c16033ad027b143c90fa5b3cc583c5713"} 420 | ]` 421 | 422 | assert.JSONEq(t, expected, string(resp.(*model.MutatingAdmissionResponse).JSONPatchPatch)) 423 | assert.Nil(t, resp.(*model.MutatingAdmissionResponse).Warnings) 424 | assert.NoError(t, err, "Webhook executed without errors") 425 | } 426 | -------------------------------------------------------------------------------- /test/curl.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | data='{"kind":"AdmissionReview","apiVersion":"admission.k8s.io/v1","request":{"uid":"c78e0c58-7389-4838-b4f5-28d6005c1cc3","kind":{"group":"","version":"v1","kind":"Pod"},"resource":{"group":"","version":"v1","resource":"pods"},"requestKind":{"group":"","version":"v1","kind":"Pod"},"requestResource":{"group":"","version":"v1","resource":"pods"},"name":"nginx28","namespace":"default","operation":"CREATE","userInfo":{"username":"kubernetes-admin","groups":["system:masters","system:authenticated"]},"object":{"kind":"Pod","apiVersion":"v1","metadata":{"name":"nginx28","creationTimestamp":null,"labels":{"run":"nginx28"}},"spec":{"volumes":[{"name":"default-token-fjtvr","secret":{"secretName":"default-token-fjtvr"}}],"containers":[{"name":"nginx28","image":"nginx","resources":{},"volumeMounts":[{"name":"default-token-fjtvr","readOnly":true,"mountPath":"/var/run/secrets/kubernetes.io/serviceaccount"}],"terminationMessagePath":"/dev/termination-log","terminationMessagePolicy":"File","imagePullPolicy":"Always"}],"restartPolicy":"Never","terminationGracePeriodSeconds":30,"dnsPolicy":"ClusterFirst","serviceAccountName":"default","serviceAccount":"default","securityContext":{},"schedulerName":"default-scheduler","tolerations":[{"key":"node.kubernetes.io/not-ready","operator":"Exists","effect":"NoExecute","tolerationSeconds":300},{"key":"node.kubernetes.io/unreachable","operator":"Exists","effect":"NoExecute","tolerationSeconds":300}],"priority":0,"enableServiceLinks":true},"status":{}},"oldObject":null,"dryRun":false,"options":{"kind":"CreateOptions","apiVersion":"meta.k8s.io/v1","fieldManager":"kubectl-run"}}}' 4 | 5 | curl --data "$data" http://127.0.0.1:8080/webhook 6 | -------------------------------------------------------------------------------- /test/e2e_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | // +build integration 3 | 4 | package test 5 | 6 | import ( 7 | "bytes" 8 | "encoding/base64" 9 | "fmt" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strings" 14 | "testing" 15 | "time" 16 | 17 | awssdk "github.com/aws/aws-sdk-go/aws" 18 | "github.com/aws/aws-sdk-go/service/ecr" 19 | "github.com/gruntwork-io/terratest/modules/aws" 20 | "github.com/gruntwork-io/terratest/modules/helm" 21 | "github.com/gruntwork-io/terratest/modules/k8s" 22 | "github.com/gruntwork-io/terratest/modules/logger" 23 | "github.com/gruntwork-io/terratest/modules/random" 24 | "github.com/gruntwork-io/terratest/modules/shell" 25 | test_structure "github.com/gruntwork-io/terratest/modules/test-structure" 26 | terratesttesting "github.com/gruntwork-io/terratest/modules/testing" 27 | "github.com/stretchr/testify/require" 28 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 29 | ) 30 | 31 | // IsKindCluster returns true if the underlying kubernetes cluster is kind. This is determined by getting the 32 | // associated nodes and checking if a node is named "kind-control-plane". 33 | //func IsKindCluster(t terratestTesting.TestingT, options *k8s.KubectlOptions) (bool, error) { 34 | // nodes, err := k8s.GetNodesE(t, options) 35 | // if err != nil { 36 | // return false, err 37 | // } 38 | // 39 | // // ASSUMPTION: All minikube setups will have nodes with labels that are namespaced with minikube.k8s.io 40 | // for _, node := range nodes { 41 | // if !nodeHasMinikubeLabel(node) { 42 | // return false, nil 43 | // } 44 | // } 45 | // 46 | // // At this point we know that all the nodes in the cluster has the minikube label, so we return true. 47 | // return true, nil 48 | //} 49 | 50 | // nodeHasMinikubeLabel returns true if any of the labels on the node is namespaced with minikube.k8s.io 51 | //func nodeHasMinikubeLabel(node corev1.Node) bool { 52 | // labels := node.GetLabels() 53 | // for key, _ := range labels { 54 | // if strings.HasPrefix(key, "minikube.k8s.io") { 55 | // return true 56 | // } 57 | // } 58 | // return false 59 | //} 60 | 61 | // This file contains examples of how to use terratest to test helm charts by deploying the chart and verifying the 62 | // deployment by hitting the service endpoint. 63 | func TestHelmDeployment(t *testing.T) { 64 | workingDir, _ := filepath.Abs("..") 65 | 66 | awsAccountID := os.Getenv("AWS_ACCOUNT_ID") 67 | awsRegion := os.Getenv("AWS_DEFAULT_REGION") 68 | awsAccessKeyID := os.Getenv("AWS_ACCESS_KEY_ID") 69 | awsSecretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY") 70 | ecrRegistry := awsAccountID + ".dkr.ecr." + awsRegion + ".amazonaws.com" 71 | ecrRepository := "docker.io/library/nginx" 72 | 73 | logger.Default = logger.New(newSensitiveLogger( 74 | logger.Default, 75 | []*regexp.Regexp{ 76 | regexp.MustCompile(awsAccountID), 77 | regexp.MustCompile(awsAccessKeyID), 78 | regexp.MustCompile(awsSecretAccessKey), 79 | regexp.MustCompile(`(--docker-password=)\S+`), 80 | }, 81 | )) 82 | 83 | // To ensure we can reuse the resource config on the same cluster to test different scenarios, we setup a unique 84 | // namespace for the resources for this test. 85 | // Note that namespaces must be lowercase. 86 | namespaceName := fmt.Sprintf("k8s-image-swapper-%s", strings.ToLower(random.UniqueId())) 87 | releaseName := fmt.Sprintf("k8s-image-swapper-%s", 88 | strings.ToLower(random.UniqueId())) 89 | 90 | // Setup the kubectl config and context. Here we choose to use the defaults, which is: 91 | // - HOME/.kube/config for the kubectl config file 92 | // - Current context of the kubectl config file 93 | kubectlOptions := k8s.NewKubectlOptions("", "", namespaceName) 94 | 95 | // Init ECR client 96 | ecrClient := aws.NewECRClient(t, awsRegion) 97 | 98 | defer test_structure.RunTestStage(t, "cleanup_aws", func() { 99 | _, err := ecrClient.DeleteRepository(&ecr.DeleteRepositoryInput{ 100 | RepositoryName: awssdk.String(ecrRepository), 101 | RegistryId: awssdk.String(awsAccountID), 102 | Force: awssdk.Bool(true), 103 | }) 104 | require.NoError(t, err) 105 | }) 106 | 107 | defer test_structure.RunTestStage(t, "cleanup_k8s", func() { 108 | // Return the output before cleanup - helps in debugging 109 | k8s.RunKubectl(t, kubectlOptions, "logs", "--selector=app.kubernetes.io/name=k8s-image-swapper", "--tail=-1") 110 | helm.Delete(t, &helm.Options{KubectlOptions: kubectlOptions}, releaseName, true) 111 | k8s.DeleteNamespace(t, kubectlOptions, namespaceName) 112 | }) 113 | 114 | test_structure.RunTestStage(t, "build_and_load_docker_image", func() { 115 | // Generate docker image to be tested 116 | shell.RunCommand(t, shell.Command{ 117 | Command: "goreleaser", 118 | Args: []string{"release", "--snapshot", "--skip-publish", "--rm-dist"}, 119 | WorkingDir: workingDir, 120 | }) 121 | 122 | // Tag with "local" to ensure kind is not pulling from the GitHub Registry 123 | shell.RunCommand(t, shell.Command{ 124 | Command: "docker", 125 | Args: []string{"tag", "ghcr.io/estahn/k8s-image-swapper:latest", "local/k8s-image-swapper:latest"}, 126 | }) 127 | 128 | // Load generated docker image into kind 129 | shell.RunCommand(t, shell.Command{ 130 | Command: "kind", 131 | Args: []string{"load", "docker-image", "local/k8s-image-swapper:latest"}, 132 | }) 133 | }) 134 | 135 | test_structure.RunTestStage(t, "deploy_webhook", func() { 136 | k8s.CreateNamespace(t, kubectlOptions, namespaceName) 137 | 138 | // Setup permissions for kind to be able to pull from ECR 139 | ecrAuthToken, _ := ecrClient.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{}) 140 | ecrDecodedAuthToken, _ := base64.StdEncoding.DecodeString(*ecrAuthToken.AuthorizationData[0].AuthorizationToken) 141 | ecrUsernamePassword := bytes.Split(ecrDecodedAuthToken, []byte(":")) 142 | 143 | secretName := awsRegion + "-ecr-registry" 144 | k8s.RunKubectl(t, kubectlOptions, "create", "secret", "docker-registry", 145 | secretName, 146 | "--docker-server="+*ecrAuthToken.AuthorizationData[0].ProxyEndpoint, 147 | "--docker-username="+string(ecrUsernamePassword[0]), 148 | "--docker-password="+string(ecrUsernamePassword[1]), 149 | "--docker-email=anymail.doesnt.matter@email.com", 150 | ) 151 | k8s.RunKubectl(t, kubectlOptions, "patch", "serviceaccount", "default", "-p", 152 | fmt.Sprintf("{\"imagePullSecrets\":[{\"name\":\"%s\"}]}", secretName), 153 | ) 154 | 155 | // Setup the args. For this test, we will set the following input values: 156 | options := &helm.Options{ 157 | KubectlOptions: kubectlOptions, 158 | SetValues: map[string]string{ 159 | "config.logFormat": "console", 160 | "config.logLevel": "debug", 161 | "config.dryRun": "false", 162 | "config.target.aws.accountId": awsAccountID, 163 | "config.target.aws.region": awsRegion, 164 | "config.imageSwapPolicy": "always", 165 | "config.imageCopyPolicy": "delayed", 166 | "config.source.filters[0].jmespath": "obj.metadata.name != 'nginx'", 167 | "awsSecretName": "k8s-image-swapper-aws", 168 | "image.repository": "local/k8s-image-swapper", 169 | "image.tag": "latest", 170 | }, 171 | } 172 | 173 | k8s.RunKubectl(t, kubectlOptions, "create", "secret", "generic", "k8s-image-swapper-aws", 174 | fmt.Sprintf("--from-literal=aws_access_key_id=%s", awsAccessKeyID), 175 | fmt.Sprintf("--from-literal=aws_secret_access_key=%s", awsSecretAccessKey), 176 | ) 177 | 178 | // Deploy the chart using `helm install`. Note that we use the version without `E`, since we want to assert the 179 | // install succeeds without any errors. 180 | helm.Install(t, options, "estahn/k8s-image-swapper", releaseName) 181 | }) 182 | 183 | test_structure.RunTestStage(t, "validate", func() { 184 | k8s.WaitUntilNumPodsCreated(t, kubectlOptions, metav1.ListOptions{LabelSelector: "app.kubernetes.io/name=k8s-image-swapper"}, 1, 30, 10*time.Second) 185 | k8s.WaitUntilServiceAvailable(t, kubectlOptions, releaseName, 30, 10*time.Second) 186 | 187 | // Launch nginx container to verify functionality 188 | k8s.RunKubectl(t, kubectlOptions, "run", "nginx", "--image=nginx", "--restart=Never") 189 | k8s.WaitUntilPodAvailable(t, kubectlOptions, "nginx", 30, 10*time.Second) 190 | 191 | // Verify container is running with images from ECR. 192 | // Implicit proof for repository creation and images pull/push via k8s-image-swapper. 193 | nginxPod := k8s.GetPod(t, kubectlOptions, "nginx") 194 | 195 | require.Equal(t, ecrRegistry+"/"+ecrRepository+":latest", nginxPod.Spec.Containers[0].Image, "container should be prefixed with ECR address") 196 | }) 197 | } 198 | 199 | type sensitiveLogger struct { 200 | logger logger.TestLogger 201 | patterns []*regexp.Regexp 202 | } 203 | 204 | func newSensitiveLogger(logger *logger.Logger, patterns []*regexp.Regexp) *sensitiveLogger { 205 | return &sensitiveLogger{ 206 | logger: logger, 207 | patterns: patterns, 208 | } 209 | } 210 | 211 | func (l *sensitiveLogger) Logf(t terratesttesting.TestingT, format string, args ...interface{}) { 212 | var redactedArgs []interface{} 213 | 214 | obfuscateWith := "$1*******" 215 | 216 | redactedArgs = args 217 | 218 | for _, pattern := range l.patterns { 219 | for i, arg := range redactedArgs { 220 | switch arg := arg.(type) { 221 | case string: 222 | redactedArgs[i] = pattern.ReplaceAllString(arg, obfuscateWith) 223 | case []string: 224 | var result []string 225 | for _, s := range arg { 226 | result = append(result, pattern.ReplaceAllString(s, obfuscateWith)) 227 | } 228 | redactedArgs[i] = result 229 | default: 230 | panic("type needs implementation") 231 | } 232 | } 233 | } 234 | 235 | l.logger.Logf(t, format, redactedArgs...) 236 | } 237 | -------------------------------------------------------------------------------- /test/kind-with-registry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -o errexit 3 | 4 | # create registry container unless it already exists 5 | reg_name='kind-registry' 6 | reg_port='5000' 7 | running="$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" 8 | if [ "${running}" != 'true' ]; then 9 | docker run \ 10 | -d --restart=always -p "127.0.0.1:${reg_port}:5000" --name "${reg_name}" \ 11 | registry:2 12 | fi 13 | 14 | # create a cluster with the local registry enabled in containerd 15 | envsubst < test/kind.yaml | kind create cluster --config=- 16 | 17 | # connect the registry to the cluster network 18 | # (the network may already be connected) 19 | docker network connect "kind" "${reg_name}" || true 20 | 21 | # Document the local registry 22 | # https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry 23 | cat <