gitops-image-replacer 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitops_image_replacer/__init__.py +9 -0
- gitops_image_replacer/__main__.py +290 -0
- gitops_image_replacer-1.0.0.dist-info/METADATA +370 -0
- gitops_image_replacer-1.0.0.dist-info/RECORD +8 -0
- gitops_image_replacer-1.0.0.dist-info/WHEEL +5 -0
- gitops_image_replacer-1.0.0.dist-info/entry_points.txt +2 -0
- gitops_image_replacer-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitops_image_replacer-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gitops-image-replacer
|
|
3
|
+
#
|
|
4
|
+
# Accepts a container image format as argument and scans and updates
|
|
5
|
+
# the tag and/or the digest of an image url in one or more GitHub repositories.
|
|
6
|
+
#
|
|
7
|
+
# Author: Simon Lauger <simon@lauger.de>
|
|
8
|
+
#
|
|
9
|
+
# Notes:
|
|
10
|
+
# - Defaults to JSON config (gitops-image-replacer.json). YAML is supported as fallback.
|
|
11
|
+
# - Uses safe regex replacement with re.escape(image_name).
|
|
12
|
+
# - Uses requests.Session with retry & timeout.
|
|
13
|
+
# - Avoids printing full file contents unless --verbose is set.
|
|
14
|
+
#
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import requests
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import re
|
|
21
|
+
import base64
|
|
22
|
+
import argparse
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import yaml
|
|
25
|
+
from yaml.loader import SafeLoader
|
|
26
|
+
from requests.adapters import HTTPAdapter
|
|
27
|
+
from urllib3.util.retry import Retry
|
|
28
|
+
|
|
29
|
+
DEFAULT_CONFIG = "gitops-image-replacer.json"
|
|
30
|
+
TAG_DIGEST_PATTERN = r'(:(?P<tag>[\w.\-_]{1,127})|)?(@(?P<digest>sha256:[a-f0-9]{64}))?'
|
|
31
|
+
|
|
32
|
+
def make_session():
|
|
33
|
+
sess = requests.Session()
|
|
34
|
+
retries = Retry(
|
|
35
|
+
total=5,
|
|
36
|
+
read=5,
|
|
37
|
+
connect=5,
|
|
38
|
+
backoff_factor=0.5,
|
|
39
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
40
|
+
allowed_methods=frozenset(["GET", "PUT", "HEAD"]),
|
|
41
|
+
raise_on_status=False
|
|
42
|
+
)
|
|
43
|
+
adapter = HTTPAdapter(max_retries=retries)
|
|
44
|
+
sess.mount("https://", adapter)
|
|
45
|
+
sess.mount("http://", adapter)
|
|
46
|
+
return sess
|
|
47
|
+
|
|
48
|
+
def main():
|
|
49
|
+
parser = argparse.ArgumentParser(description='command line arguments.')
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
'--config',
|
|
52
|
+
metavar='<file>',
|
|
53
|
+
type=str,
|
|
54
|
+
help=f'configuration file (defaults to "{DEFAULT_CONFIG}")',
|
|
55
|
+
default=DEFAULT_CONFIG,
|
|
56
|
+
required=False,
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
'--apply',
|
|
60
|
+
action='store_true',
|
|
61
|
+
help='if set the changes will be applied to the repository, otherwise the script runs in dry-run mode',
|
|
62
|
+
required=False,
|
|
63
|
+
)
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
'--ci',
|
|
66
|
+
action='store_true',
|
|
67
|
+
help='enable the CI mode, which validates the environment variable GIT_REF against patterns in the config file',
|
|
68
|
+
required=False,
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
'image',
|
|
72
|
+
metavar='<string>',
|
|
73
|
+
help='docker image (e.g. docker.io/foo/bar:2.0.0)',
|
|
74
|
+
type=str,
|
|
75
|
+
default=None,
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument('--name',
|
|
78
|
+
metavar='<string>',
|
|
79
|
+
help='author name which is used during the commit of the changes (env: GIT_COMMIT_NAME)',
|
|
80
|
+
type=str,
|
|
81
|
+
default=os.getenv('GIT_COMMIT_NAME', 'Replacer Bot'),
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument('--email',
|
|
84
|
+
metavar='<string>',
|
|
85
|
+
help='email which is used during the commit of the changes (env: GIT_COMMIT_EMAIL)',
|
|
86
|
+
type=str,
|
|
87
|
+
default=os.getenv('GIT_COMMIT_EMAIL', 'replacer-bot@localhost.localdomain'),
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument('--message',
|
|
90
|
+
metavar='<string>',
|
|
91
|
+
help='commit message template (default to "fix: update image to {}")',
|
|
92
|
+
type=str,
|
|
93
|
+
default='fix: update image to {}',
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument('--api',
|
|
96
|
+
metavar='<string>',
|
|
97
|
+
help='URL to the GitHub API (default: "https://api.github.com"; env: GITHUB_API_URL)',
|
|
98
|
+
type=str,
|
|
99
|
+
default=os.getenv('GITHUB_API_URL', 'https://api.github.com'),
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument('--verbose',
|
|
102
|
+
action='store_true',
|
|
103
|
+
help='enable verbose logging (prints file contents and desired state)',
|
|
104
|
+
required=False,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
args = parser.parse_args()
|
|
108
|
+
|
|
109
|
+
# get variables from environment
|
|
110
|
+
git_ref = os.getenv('GIT_REF', None)
|
|
111
|
+
github_token = os.getenv('GITHUB_TOKEN', None)
|
|
112
|
+
|
|
113
|
+
# validate variables
|
|
114
|
+
if not github_token:
|
|
115
|
+
print("error: GITHUB_TOKEN is not set")
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
|
|
118
|
+
if args.ci and not git_ref:
|
|
119
|
+
print("error: GIT_REF is not set (required in --ci mode)")
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
# load config file
|
|
123
|
+
if not os.path.exists(args.config):
|
|
124
|
+
print(f"error: config file {args.config} does not exist")
|
|
125
|
+
sys.exit(1)
|
|
126
|
+
|
|
127
|
+
with open(args.config, 'r') as f:
|
|
128
|
+
if args.config.endswith('.json'):
|
|
129
|
+
config = json.load(f)
|
|
130
|
+
else:
|
|
131
|
+
config = yaml.load(f, Loader=SafeLoader)
|
|
132
|
+
|
|
133
|
+
if 'gitops-image-replacer' not in config:
|
|
134
|
+
print("info: no gitops-image-replacer entry found in config, exiting")
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
|
|
137
|
+
# validate image and capture components
|
|
138
|
+
image_re = r'(?P<repository>[\w.\-_]+((?::\d+|)(?=/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+))|)(?:/|)(?P<image>[a-zA-Z0-9.\-_]+(?:/[a-zA-Z0-9.\-_]+|))' + TAG_DIGEST_PATTERN
|
|
139
|
+
result = re.search(image_re, args.image)
|
|
140
|
+
if not result:
|
|
141
|
+
print(f"error: it seems that '{args.image}' is not a valid image url")
|
|
142
|
+
sys.exit(1)
|
|
143
|
+
|
|
144
|
+
repository_part = result.group('repository') or ''
|
|
145
|
+
image_name = (repository_part + '/' if repository_part else '') + result.group('image')
|
|
146
|
+
|
|
147
|
+
print("info: run replacer for image " + image_name)
|
|
148
|
+
|
|
149
|
+
# default to exit code 0
|
|
150
|
+
exit_code = 0
|
|
151
|
+
|
|
152
|
+
if not args.apply:
|
|
153
|
+
print("info: running in dry-run, no changes will be applied")
|
|
154
|
+
|
|
155
|
+
session = make_session()
|
|
156
|
+
headers = {
|
|
157
|
+
'Authorization': f'token {github_token}',
|
|
158
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
159
|
+
}
|
|
160
|
+
timeout = 30
|
|
161
|
+
|
|
162
|
+
# precheck block (cache responses for later reuse)
|
|
163
|
+
cache = {}
|
|
164
|
+
for repo in config['gitops-image-replacer']:
|
|
165
|
+
repo_path = repo['repository']
|
|
166
|
+
branch = repo['branch']
|
|
167
|
+
file_path = repo['file']
|
|
168
|
+
cache_key = f"{repo_path}:{branch}:{file_path}"
|
|
169
|
+
|
|
170
|
+
print(f"info: validate if {file_path} from repository {repo_path} in branch {branch} exists")
|
|
171
|
+
|
|
172
|
+
# Use GET for reliability; do not decode content here
|
|
173
|
+
url = f"{args.api}/repos/{repo_path}/contents/{file_path}?ref={urllib.parse.quote(branch)}"
|
|
174
|
+
if args.verbose:
|
|
175
|
+
print(url)
|
|
176
|
+
precheck = session.get(url, headers=headers, timeout=timeout)
|
|
177
|
+
if precheck.status_code == 401:
|
|
178
|
+
print("error: 401 unauthorized - maybe your token does not have access to the defined repository")
|
|
179
|
+
exit_code = 1
|
|
180
|
+
elif precheck.status_code == 404:
|
|
181
|
+
print("error: 404 not found - make sure that the file exists in the defined target")
|
|
182
|
+
exit_code = 1
|
|
183
|
+
elif precheck.status_code != 200:
|
|
184
|
+
print(f"error: unknown error with HTTP code {precheck.status_code}")
|
|
185
|
+
exit_code = 1
|
|
186
|
+
else:
|
|
187
|
+
# Cache successful response for later reuse
|
|
188
|
+
cache[cache_key] = precheck.json()
|
|
189
|
+
|
|
190
|
+
if exit_code != 0:
|
|
191
|
+
sys.exit(exit_code)
|
|
192
|
+
|
|
193
|
+
# replace block
|
|
194
|
+
for repo in config['gitops-image-replacer']:
|
|
195
|
+
repo_path = repo['repository']
|
|
196
|
+
branch = repo['branch']
|
|
197
|
+
file_path = repo['file']
|
|
198
|
+
|
|
199
|
+
if args.ci:
|
|
200
|
+
if 'when' in repo:
|
|
201
|
+
if not re.match(repo['when'], git_ref or ""):
|
|
202
|
+
print(f"info: git-ref {git_ref} does not match when pattern ('{repo['when']}')")
|
|
203
|
+
continue
|
|
204
|
+
else:
|
|
205
|
+
print(f"info: git-ref {git_ref} matches when pattern ('{repo['when']}')")
|
|
206
|
+
if 'except' in repo:
|
|
207
|
+
if re.match(repo['except'], git_ref or ""):
|
|
208
|
+
print(f"info: git-ref {git_ref} matches except pattern ('{repo['except']}')")
|
|
209
|
+
continue
|
|
210
|
+
else:
|
|
211
|
+
print(f"info: git-ref {git_ref} does not match except pattern ('{repo['except']}')")
|
|
212
|
+
|
|
213
|
+
# get file (reuse cached data from precheck if available)
|
|
214
|
+
cache_key = f"{repo_path}:{branch}:{file_path}"
|
|
215
|
+
if cache_key in cache:
|
|
216
|
+
print(f"info: using cached data for {file_path} from repository {repo_path}")
|
|
217
|
+
fetch_json = cache[cache_key]
|
|
218
|
+
else:
|
|
219
|
+
print(f"info: fetch {file_path} from repository {repo_path} in branch {branch}")
|
|
220
|
+
fetch_url = f"{args.api}/repos/{repo_path}/contents/{file_path}?ref={urllib.parse.quote(branch)}"
|
|
221
|
+
fetch = session.get(fetch_url, headers=headers, timeout=timeout)
|
|
222
|
+
if fetch.status_code != 200:
|
|
223
|
+
try:
|
|
224
|
+
fetch_json = fetch.json()
|
|
225
|
+
msg = fetch_json.get('message', 'unknown error')
|
|
226
|
+
except Exception:
|
|
227
|
+
msg = 'unknown error'
|
|
228
|
+
print(f"error: {msg}")
|
|
229
|
+
exit_code = 1
|
|
230
|
+
continue
|
|
231
|
+
fetch_json = fetch.json()
|
|
232
|
+
|
|
233
|
+
content_original = base64.b64decode(fetch_json['content']).decode('utf-8')
|
|
234
|
+
|
|
235
|
+
if args.verbose:
|
|
236
|
+
print(f"info: original content of {file_path}:")
|
|
237
|
+
print(f"#### BEGIN OF SOURCE FILE {file_path} ####")
|
|
238
|
+
print(content_original)
|
|
239
|
+
print(f"#### END OF SOURCE FILE {file_path} ####")
|
|
240
|
+
|
|
241
|
+
# replace image in target file
|
|
242
|
+
pattern = re.escape(image_name) + TAG_DIGEST_PATTERN
|
|
243
|
+
content = re.sub(pattern, args.image, content_original)
|
|
244
|
+
|
|
245
|
+
# check for changes
|
|
246
|
+
if content == content_original:
|
|
247
|
+
print(f"info: no outstanding changes are found in file {file_path}")
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
if args.verbose:
|
|
251
|
+
print(f"info: desired content of {file_path}:")
|
|
252
|
+
print(f"#### BEGIN OF DESIRED FILE {file_path} ####")
|
|
253
|
+
print(content)
|
|
254
|
+
print(f"#### END OF DESIRED FILE {file_path} ####")
|
|
255
|
+
|
|
256
|
+
if not args.apply:
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
# update file in repository
|
|
260
|
+
print(f"info: update {file_path} from repository {repo_path} in branch {branch}")
|
|
261
|
+
put_url = f"{args.api}/repos/{repo_path}/contents/{file_path}"
|
|
262
|
+
update = session.put(
|
|
263
|
+
put_url,
|
|
264
|
+
headers={**headers, 'Content-Type': 'application/json'},
|
|
265
|
+
data=json.dumps({
|
|
266
|
+
'committer': {
|
|
267
|
+
'name': args.name,
|
|
268
|
+
'email': args.email,
|
|
269
|
+
},
|
|
270
|
+
'message': args.message.format(args.image),
|
|
271
|
+
'branch': branch,
|
|
272
|
+
'content': base64.b64encode(content.encode('utf-8')).decode(),
|
|
273
|
+
'sha': fetch_json['sha']
|
|
274
|
+
}),
|
|
275
|
+
timeout=timeout
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
update_json = update.json()
|
|
280
|
+
print(json.dumps(update_json, indent=4))
|
|
281
|
+
except Exception:
|
|
282
|
+
print("warn: could not decode update response as JSON")
|
|
283
|
+
|
|
284
|
+
if update.status_code not in (200, 201):
|
|
285
|
+
exit_code = 1
|
|
286
|
+
|
|
287
|
+
sys.exit(exit_code)
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__":
|
|
290
|
+
main()
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitops-image-replacer
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Automated container image updates for GitOps repositories via GitHub API
|
|
5
|
+
Author-email: Simon Lauger <simon@lauger.de>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/slauger/gitops-image-replacer
|
|
8
|
+
Project-URL: Documentation, https://github.com/slauger/gitops-image-replacer#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/slauger/gitops-image-replacer
|
|
10
|
+
Project-URL: Issues, https://github.com/slauger/gitops-image-replacer/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/slauger/gitops-image-replacer/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: gitops,docker,kubernetes,container,image,automation,github,ci-cd,deployment
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
23
|
+
Classifier: Operating System :: OS Independent
|
|
24
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
25
|
+
Classifier: Topic :: System :: Systems Administration
|
|
26
|
+
Classifier: Topic :: Utilities
|
|
27
|
+
Requires-Python: >=3.8
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: requests>=2.25.0
|
|
31
|
+
Requires-Dist: PyYAML>=5.4.0
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# gitops-image-replacer
|
|
35
|
+
|
|
36
|
+
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](https://pypi.org/project/gitops-image-replacer/)
|
|
39
|
+
[](https://pypi.org/project/gitops-image-replacer/)
|
|
40
|
+
|
|
41
|
+
A lightweight CLI tool that automates container image updates in GitOps repositories. Replace image tags and/or digests across multiple GitHub repositories with a single command, enabling automated deployment workflows.
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- **Flexible Modes**: Dry-run for validation, apply mode for commits
|
|
46
|
+
- **CI/CD Integration**: Built-in CI mode with `GIT_REF` pattern matching
|
|
47
|
+
- **Complete Image Support**: Handles tags, digests, and combined references (e.g., `registry.io/ns/app:1.2.3@sha256:...`)
|
|
48
|
+
- **Multiple Repositories**: Update images across any number of repos and files
|
|
49
|
+
- **Configuration Formats**: JSON (default) and YAML support
|
|
50
|
+
- **Performance Optimized**: Response caching eliminates duplicate API calls
|
|
51
|
+
- **Safe Operations**: Regex escaping prevents unintended replacements
|
|
52
|
+
- **Robust HTTP**: Automatic retries, timeouts, and error handling
|
|
53
|
+
- **Clean Logging**: Minimal output by default, verbose mode for debugging
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
- Python 3.8+
|
|
58
|
+
- A GitHub/GitHub Enterprise token with content read/write access
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
### Via pip (recommended)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Install from PyPI
|
|
66
|
+
pip install gitops-image-replacer
|
|
67
|
+
|
|
68
|
+
# Verify installation
|
|
69
|
+
gitops-image-replacer --help
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### From source
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Clone repository
|
|
76
|
+
git clone https://github.com/slauger/gitops-image-replacer.git
|
|
77
|
+
cd gitops-image-replacer
|
|
78
|
+
|
|
79
|
+
# Install in development mode
|
|
80
|
+
pip install -e .
|
|
81
|
+
|
|
82
|
+
# Or run directly
|
|
83
|
+
python -m gitops_image_replacer --help
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Quick Start
|
|
87
|
+
|
|
88
|
+
1. Create a configuration file (default: `gitops-image-replacer.json`).
|
|
89
|
+
2. Run a dry-run:
|
|
90
|
+
```bash
|
|
91
|
+
gitops-image-replacer docker.io/example/app:2.0.0
|
|
92
|
+
|
|
93
|
+
gitops-image-replacer docker.io/example/app:2.0.0@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
|
|
94
|
+
|
|
95
|
+
gitops-image-replacer docker.io/example/app@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
|
|
96
|
+
|
|
97
|
+
gitops-image-replacer --config gitops-image-replacer.json docker.io/example/app:2.0.0
|
|
98
|
+
```
|
|
99
|
+
3. Apply changes (commit to target repos):
|
|
100
|
+
```bash
|
|
101
|
+
gitops-image-replacer --apply docker.io/example/app:2.0.0
|
|
102
|
+
|
|
103
|
+
gitops-image-replacer --config gitops-image-replacer.json --apply docker.io/example/app:2.0.0
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## CLI
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
usage: gitops-image-replacer [-h] [--config <file>] [--apply] [--ci]
|
|
110
|
+
[--name <string>] [--email <string>]
|
|
111
|
+
[--message <string>] [--api <string>]
|
|
112
|
+
[--verbose]
|
|
113
|
+
<string>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- `--config` Path to the configuration file (default: `gitops-image-replacer.json`). JSON recommended.
|
|
117
|
+
- `--apply` Apply changes (commit). Without this flag the tool runs in dry-run.
|
|
118
|
+
- `--ci` CI mode: validates `GIT_REF` against `when`/`except` regex patterns from config.
|
|
119
|
+
- `--name` Commit author name (default: env `GIT_COMMIT_NAME` or `Replacer Bot`).
|
|
120
|
+
- `--email` Commit author email (default: env `GIT_COMMIT_EMAIL` or `replacer-bot@localhost.localdomain`).
|
|
121
|
+
- `--message` Commit message template (default: `fix: update image to {}`).
|
|
122
|
+
- `--api` GitHub API URL (default: env `GITHUB_API_URL` or `https://github.com/api/v3`).
|
|
123
|
+
- `--verbose` Print file contents and desired state (use with care in CI logs).
|
|
124
|
+
- Positional: `image` (e.g., `docker.io/foo/bar:2.0.0`). Tag and digest are optional.
|
|
125
|
+
|
|
126
|
+
### Environment
|
|
127
|
+
|
|
128
|
+
- `GITHUB_TOKEN` **(required)** – token with access to read/write repository contents.
|
|
129
|
+
- `GIT_REF` *(required when `--ci`)* – the current ref string, e.g., `refs/heads/main`.
|
|
130
|
+
|
|
131
|
+
Recommended token scopes:
|
|
132
|
+
- Public repos only: `public_repo`
|
|
133
|
+
- Private repos: `repo`
|
|
134
|
+
- GitHub Enterprise: equivalent content permissions
|
|
135
|
+
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
Default format is **JSON**. YAML (`.yaml`/`.yml`) is supported as well.
|
|
139
|
+
|
|
140
|
+
### JSON schema (per entry)
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"gitops-image-replacer": [
|
|
145
|
+
{
|
|
146
|
+
"repository": "org/repo",
|
|
147
|
+
"branch": "main",
|
|
148
|
+
"file": "path/to/values.yaml",
|
|
149
|
+
"when": "^refs/heads/(main|release/.*)$",
|
|
150
|
+
"except": "^refs/heads/feature/"
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Fields**
|
|
157
|
+
|
|
158
|
+
- `repository` (string): `ORG/REPO` path on GitHub/GHE
|
|
159
|
+
- `branch` (string): target branch
|
|
160
|
+
- `file` (string): target file path in the repository
|
|
161
|
+
- `when` (string, optional): regex that must match `GIT_REF` when `--ci` is enabled
|
|
162
|
+
- `except` (string, optional): regex that must **not** match `GIT_REF` when `--ci` is enabled
|
|
163
|
+
|
|
164
|
+
> The tool uses `re.match` (anchored at the string start). Use `^...$` in your patterns if you require a full match.
|
|
165
|
+
|
|
166
|
+
### Examples
|
|
167
|
+
|
|
168
|
+
**JSON (default)**
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"gitops-image-replacer": [
|
|
173
|
+
{
|
|
174
|
+
"repository": "acme/online-shop",
|
|
175
|
+
"branch": "main",
|
|
176
|
+
"file": "deploy/values.yaml",
|
|
177
|
+
"when": "^refs/heads/(main|release/.*)$"
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"repository": "acme/payments",
|
|
181
|
+
"branch": "develop",
|
|
182
|
+
"file": "charts/payments/values.yaml",
|
|
183
|
+
"except": "^refs/heads/legacy/"
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**YAML (alternative)**
|
|
190
|
+
|
|
191
|
+
```yaml
|
|
192
|
+
gitops-image-replacer:
|
|
193
|
+
- repository: acme/online-shop
|
|
194
|
+
branch: main
|
|
195
|
+
file: deploy/values.yaml
|
|
196
|
+
when: '^refs/heads/(main|release/.*)$'
|
|
197
|
+
- repository: acme/payments
|
|
198
|
+
branch: develop
|
|
199
|
+
file: charts/payments/values.yaml
|
|
200
|
+
except: '^refs/heads/legacy/'
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Supported Image Formats
|
|
204
|
+
|
|
205
|
+
The tool validates and matches container images using a comprehensive regex pattern. All components are optional except the image name.
|
|
206
|
+
|
|
207
|
+
### Regex Pattern
|
|
208
|
+
|
|
209
|
+
```regex
|
|
210
|
+
(?P<repository>[\w.\-_]+((?::\d+|)(?=/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+))|)(?:/|)(?P<image>[a-zA-Z0-9.\-_]+(?:/[a-zA-Z0-9.\-_]+|))(:(?P<tag>[\w.\-_]{1,127})|)?(@(?P<digest>sha256:[a-f0-9]{64}))?
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Pattern Components
|
|
214
|
+
|
|
215
|
+
- **Repository/Registry** (optional): `docker.io`, `gcr.io`, `registry.example.com:5000`
|
|
216
|
+
- Supports hostnames with optional ports
|
|
217
|
+
- Alphanumeric, dots, dashes, underscores
|
|
218
|
+
- **Image Name** (required): `library/nginx`, `myorg/myapp`, `MyApp`
|
|
219
|
+
- Case-sensitive (supports both uppercase and lowercase)
|
|
220
|
+
- Can include organization/namespace
|
|
221
|
+
- **Tag** (optional): `:latest`, `:v1.2.3`, `:20241125-abc123`
|
|
222
|
+
- Max 127 characters
|
|
223
|
+
- Alphanumeric, dots, dashes, underscores
|
|
224
|
+
- **Digest** (optional): `@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1`
|
|
225
|
+
- SHA256 hash (64 hex characters, lowercase)
|
|
226
|
+
|
|
227
|
+
### Valid Examples
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
# Simple image name
|
|
231
|
+
nginx
|
|
232
|
+
|
|
233
|
+
# With registry and tag
|
|
234
|
+
docker.io/library/nginx:1.25
|
|
235
|
+
|
|
236
|
+
# With digest only
|
|
237
|
+
gcr.io/myproject/myapp@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
|
|
238
|
+
|
|
239
|
+
# Tag and digest (immutable reference)
|
|
240
|
+
registry.example.com:5000/org/app:v2.0.0@sha256:abc...
|
|
241
|
+
|
|
242
|
+
# Case-sensitive names
|
|
243
|
+
ghcr.io/MyOrg/MyApp:latest
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## How it works
|
|
247
|
+
|
|
248
|
+
1. **Validation**: Checks CLI arguments, environment variables, and configuration file
|
|
249
|
+
2. **Precheck Phase**: Validates access to all target repositories/files (caches responses)
|
|
250
|
+
3. **Replace Phase**: Downloads files (reuses cached data), performs safe regex replacement
|
|
251
|
+
4. **Commit Phase**: If `--apply` is set and changes detected, commits via GitHub Contents API
|
|
252
|
+
5. **Exit Codes**: Returns `0` on success, non-zero on failures
|
|
253
|
+
|
|
254
|
+
### Performance Optimization
|
|
255
|
+
|
|
256
|
+
The tool caches file contents from the precheck phase, eliminating duplicate API calls during the replace phase. This reduces GitHub API usage by approximately 50% in typical scenarios.
|
|
257
|
+
|
|
258
|
+
## Exit Codes
|
|
259
|
+
|
|
260
|
+
- `0` success (no changes or committed changes)
|
|
261
|
+
- `1` validation or API error
|
|
262
|
+
|
|
263
|
+
## Best Practices
|
|
264
|
+
|
|
265
|
+
### Testing and Validation
|
|
266
|
+
- **Always start with dry-run**: Test your configuration without `--apply` first
|
|
267
|
+
- **Use verbose mode carefully**: `--verbose` prints file contents - avoid in CI logs with sensitive data
|
|
268
|
+
- **Validate regex patterns**: Use `^...$` anchors in `when`/`except` for full string matches
|
|
269
|
+
|
|
270
|
+
### Configuration Management
|
|
271
|
+
- **Keep config versioned**: Store `gitops-image-replacer.json` in your repository
|
|
272
|
+
- **Use meaningful names**: Standard naming makes the file's purpose obvious
|
|
273
|
+
- **Document patterns**: Comment your regex patterns (especially in CI mode)
|
|
274
|
+
|
|
275
|
+
### Security
|
|
276
|
+
- **Limit token scope**: Use minimal permissions (contents: write)
|
|
277
|
+
- **Rotate tokens**: Regularly update GitHub tokens
|
|
278
|
+
- **Review changes**: Use dry-run before production deployments
|
|
279
|
+
|
|
280
|
+
### Performance
|
|
281
|
+
- **Minimize targets**: Only configure files that need updates
|
|
282
|
+
- **Use CI patterns wisely**: `when`/`except` patterns reduce unnecessary runs
|
|
283
|
+
- **Leverage caching**: The tool automatically caches API responses
|
|
284
|
+
|
|
285
|
+
## Troubleshooting
|
|
286
|
+
|
|
287
|
+
### Common Issues
|
|
288
|
+
|
|
289
|
+
**401 Unauthorized**
|
|
290
|
+
- Verify `GITHUB_TOKEN` is set correctly
|
|
291
|
+
- Check token has `repo` or `public_repo` scope
|
|
292
|
+
- For GitHub Enterprise, confirm token has access to the organization
|
|
293
|
+
|
|
294
|
+
**404 Not Found**
|
|
295
|
+
- Verify `repository`, `branch`, and `file` paths in config
|
|
296
|
+
- Check branch name spelling (case-sensitive)
|
|
297
|
+
- Ensure file exists at the specified path
|
|
298
|
+
|
|
299
|
+
**No changes detected**
|
|
300
|
+
- Confirm target file contains the exact image name (case-sensitive)
|
|
301
|
+
- Tags and digests are optional in the search pattern
|
|
302
|
+
- Use `--verbose` to see file contents and verify the image reference format
|
|
303
|
+
|
|
304
|
+
**Rate limiting (429/403)**
|
|
305
|
+
- Built-in retries handle temporary rate limits
|
|
306
|
+
- Reduce execution frequency in CI/CD pipelines
|
|
307
|
+
- Consider using GitHub App tokens for higher limits
|
|
308
|
+
|
|
309
|
+
### Debug Mode
|
|
310
|
+
|
|
311
|
+
Run with `--verbose` to see:
|
|
312
|
+
- Full API URLs being called
|
|
313
|
+
- Complete file contents before replacement
|
|
314
|
+
- Desired file contents after replacement
|
|
315
|
+
|
|
316
|
+
**Warning**: Verbose mode may expose sensitive data in logs.
|
|
317
|
+
|
|
318
|
+
## Use Cases
|
|
319
|
+
|
|
320
|
+
### Automated Deployment Pipeline
|
|
321
|
+
|
|
322
|
+
Update production manifests when a new image is built:
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
# In your CI/CD pipeline after building image
|
|
326
|
+
gitops-image-replacer --apply docker.io/myorg/myapp:${CI_COMMIT_SHA}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Multi-Environment Updates
|
|
330
|
+
|
|
331
|
+
Use CI mode to update different environments based on branch:
|
|
332
|
+
|
|
333
|
+
```json
|
|
334
|
+
{
|
|
335
|
+
"gitops-image-replacer": [
|
|
336
|
+
{
|
|
337
|
+
"repository": "myorg/gitops-production",
|
|
338
|
+
"branch": "main",
|
|
339
|
+
"file": "apps/myapp/deployment.yaml",
|
|
340
|
+
"when": "^refs/heads/main$"
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"repository": "myorg/gitops-staging",
|
|
344
|
+
"branch": "main",
|
|
345
|
+
"file": "apps/myapp/deployment.yaml",
|
|
346
|
+
"when": "^refs/heads/(main|release/.*)$"
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Digest Pinning
|
|
353
|
+
|
|
354
|
+
Update images with immutable digest references:
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
gitops-image-replacer --apply \
|
|
358
|
+
registry.example.com/myapp:v2.0.0@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
## Contributing
|
|
362
|
+
|
|
363
|
+
Contributions are welcome! Please ensure:
|
|
364
|
+
- Code follows existing style and patterns
|
|
365
|
+
- Changes are tested with both dry-run and apply modes
|
|
366
|
+
- Documentation is updated for new features
|
|
367
|
+
|
|
368
|
+
## License
|
|
369
|
+
|
|
370
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
gitops_image_replacer/__init__.py,sha256=duynX0gXa4GfPhn_B6s3MH_WIF7I2Viicc-OwRXjnpo,186
|
|
2
|
+
gitops_image_replacer/__main__.py,sha256=cspt6OShHH5Nm3MADjxQguqAF_RHyHQ8xa4_obl42ME,10412
|
|
3
|
+
gitops_image_replacer-1.0.0.dist-info/licenses/LICENSE,sha256=PWKv48zlqsEbvFv-3oXpPI8yO_I11cnIldtq87mZQrI,1069
|
|
4
|
+
gitops_image_replacer-1.0.0.dist-info/METADATA,sha256=SaBL3LIWKL6BpA_gxirYQyt9yAEtjl_EBRJRG5A-v2I,12622
|
|
5
|
+
gitops_image_replacer-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
+
gitops_image_replacer-1.0.0.dist-info/entry_points.txt,sha256=6sNtuT-93pi7oH41K8WCMv65cq57cCXL1FGcolT2-s4,78
|
|
7
|
+
gitops_image_replacer-1.0.0.dist-info/top_level.txt,sha256=CTvvin9b5I4Fz478IGQ-2tE2WaxwJXguQ3puNumfpn8,22
|
|
8
|
+
gitops_image_replacer-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Simon Lauger
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gitops_image_replacer
|