├── .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 |
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 | [](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 | [](http://slack.kubernetes.io/)
46 | [](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 | [](https://github.com/estahn/k8s-image-swapper/releases/latest)
62 | [](https://artifacthub.io/packages/helm/estahn/k8s-image-swapper)
63 | [](/LICENSE.md)
64 | [](https://codecov.io/gh/estahn/k8s-image-swapper)
65 | [](http://godoc.org/github.com/estahn/k8s-image-swapper)
66 |
67 | ## :star2: Stargazers over time
68 |
69 | [](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 | { 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 |
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 | 
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 <