Skip to content

Commit 80faf77

Browse files
committed
feat(RELEASE-1989): add Python implementation for publish-index-image
Implements managed and internal publish-index-image tasks with comprehensive skopeo integration and testing infrastructure: - Add managed_publish_index_image task that processes IR results and spawns parallel internal Tekton requests for image publishing - Add internal publish_index_image task that copies index images using skopeo - Implement skopeo wrapper module with inspect, copy, and login operations - Add internal_request module for creating and managing Tekton internal requests - Implement rsmodels for internal request and secret data models - Add fake skopeo implementation for testing without registry access - Include comprehensive test coverage for all new modules - Update Dockerfile and dependencies for Python task execution Assisted-by: claude Signed-off-by: Jindrich Luza <jluza@redhat.com>
1 parent befd5d8 commit 80faf77

25 files changed

Lines changed: 7340 additions & 6 deletions

Dockerfile

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,13 @@ RUN dnf -y --setopt=tsflags=nodocs install \
9393
RUN curl -LO https://github.com/release-engineering/exodus-rsync/releases/latest/download/exodus-rsync && \
9494
chmod +x exodus-rsync && mv exodus-rsync /usr/local/bin/rsync
9595

96+
# Copy utils before installation
97+
COPY utils /home/utils
98+
9699
# Install Python dependencies using uv
97-
COPY pyproject.toml uv.lock ./
98-
RUN uv pip install -r pyproject.toml --system && \
100+
COPY README.md pyproject.toml uv.lock /home/
101+
RUN uv pip install -r /home/pyproject.toml --system && \
102+
uv --directory /home/ pip install . --system && \
99103
# Remove PyPI's python-qpid-proton so the system RPM (python3-qpid-proton) takes precedence.
100104
# The PyPI wheel bundles its own OpenSSL which doesn't use the system CA trust store.
101105
# The system RPM is properly linked to the distro's OpenSSL and respects /etc/pki/ca-trust.
@@ -108,7 +112,6 @@ ADD data/certs/2015-IT-Root-CA.pem data/certs/2022-IT-Root-CA.pem /etc/pki/ca-tr
108112
RUN update-ca-trust
109113

110114
COPY pyxis /home/pyxis
111-
COPY utils /home/utils
112115
COPY integration-tests /home/integration-tests
113116
COPY scripts /home/scripts
114117
COPY templates /home/templates
@@ -160,7 +163,7 @@ ENV PATH="$PATH:/home/publish-to-cgw-wrapper"
160163
# Flat imports: helpers and task scripts must be importable.
161164
# Tests use the same layout via pyproject [tool.pytest.ini_options] pythonpath.
162165
# Keep /home for other modules (e.g. pyxis, sbom) that expect it.
163-
ENV PYTHONPATH="/home:/home/scripts/python/helpers:/home/scripts/python/tasks/internal"
166+
ENV PYTHONPATH="/home:/home/scripts/python/helpers:/home/scripts/python/tasks/internal:/home/scripts/python/tasks/managed"
164167

165168
# uv installs newer requests and certifi which don't use the system CA like the one installed via
166169
# dnf. So we need to point requests to the system CA bundle explicitly.

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies = [
1010
"jinja2",
1111
"check-jsonschema",
1212
"jinja2-ansible-filters",
13+
"kubernetes",
1314
"packaging",
1415
"packageurl-python",
1516
"pubtools-content-gateway==0.5.4",
@@ -21,6 +22,8 @@ dependencies = [
2122
"pulp-cli==0.36.3",
2223
"diffused-lib==0.3.0",
2324
"confluent-kafka",
25+
"pydantic",
26+
"jq"
2427
]
2528

2629
[tool.black]
@@ -32,6 +35,7 @@ pythonpath = [
3235
"integration-tests/lib",
3336
"scripts/python/helpers",
3437
"scripts/python/tasks/internal",
38+
"scripts/python/tasks/managed",
3539
]
3640

3741
[dependency-groups]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Utility decorators for common patterns."""
2+
3+
from functools import wraps
4+
5+
6+
def async_in_executor(executor_instance):
7+
"""Decorator that runs function in executor and returns a Future."""
8+
9+
def decorator(func):
10+
@wraps(func)
11+
def wrapper(*args, **kwargs):
12+
return executor_instance.submit(func, *args, **kwargs)
13+
14+
return wrapper
15+
16+
return decorator
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
# Fake Skopeo Client for Testing
2+
3+
Fake skopeo allows you to mock skopeo operations (`inspect`, `copy`) by defining expected responses in a YAML configuration file.
4+
5+
## Quick Start
6+
7+
### 1. Create a mock configuration file
8+
9+
```yaml
10+
inspect:
11+
- match:
12+
image: "docker://quay.io/source/image@sha256:abc123"
13+
format: "{{.Digest}}"
14+
return: "sha256:abc123"
15+
16+
copy:
17+
- match:
18+
source: "docker://quay.io/source/image@sha256:abc123"
19+
destination: "docker://quay.io/dest/image:tag"
20+
# No return = success
21+
```
22+
23+
### 2. Use the fake client in tests
24+
25+
#### Option A: Using bash wrapper (for Tekton tests)
26+
27+
```bash
28+
# In your test setup, create a bash function that intercepts the script call
29+
publish_index_image() {
30+
python3 -c "
31+
import sys
32+
sys.argv[0] = 'publish_index_image'
33+
sys.path.insert(0, '/path/to/scripts/python/helpers')
34+
sys.path.insert(0, '/path/to/scripts/python/tasks/internal')
35+
36+
# Patch BEFORE importing publish_index_image
37+
from fake import patch_skopeo_client
38+
patch_skopeo_client()
39+
40+
# Now import and run
41+
from publish_index_image import main
42+
sys.exit(main())
43+
" "\$@"
44+
}
45+
46+
# Set the config file location
47+
export RELEASE_SERVICE_UTILS_FAKE_SKOPEO_SETUP=/path/to/mock-config.yaml
48+
49+
# Run your test
50+
publish_index_image \
51+
--source-index "quay.io/source/image@sha256:abc123" \
52+
--target-index "quay.io/dest/image:tag" \
53+
--retries 3 \
54+
--source-credential-path /path/to/src-cred \
55+
--target-credential-path /path/to/dest-cred
56+
```
57+
58+
#### Option B: Direct Python usage
59+
60+
```python
61+
import os
62+
os.environ["RELEASE_SERVICE_UTILS_FAKE_SKOPEO_SETUP"] = "/path/to/config.yaml"
63+
64+
from fake import patch_skopeo_client
65+
patch_skopeo_client()
66+
67+
# Now any code that imports SkopeoClient gets the fake version
68+
from publish_index_image import main
69+
main()
70+
```
71+
72+
## YAML Configuration Format
73+
74+
### Top-level structure
75+
76+
```yaml
77+
inspect:
78+
- match: {...}
79+
return: ...
80+
- match: {...}
81+
return: ...
82+
83+
copy:
84+
- match: {...}
85+
return: ...
86+
```
87+
88+
### Inspect rules
89+
90+
#### With format parameter (returns string)
91+
92+
```yaml
93+
inspect:
94+
- match:
95+
image: "docker://quay.io/image:tag"
96+
format: "{{.Digest}}"
97+
return: "sha256:abc123"
98+
```
99+
100+
#### Without format parameter (returns dict)
101+
102+
```yaml
103+
inspect:
104+
- match:
105+
image: "docker://quay.io/image:tag"
106+
return:
107+
Digest: "sha256:abc123"
108+
Name: "quay.io/image"
109+
RepoTags: ["v1.0", "latest"]
110+
```
111+
112+
#### Using regex
113+
114+
```yaml
115+
inspect:
116+
- match:
117+
image:
118+
regex: "docker://quay.io/.*@sha256:[a-f0-9]{64}"
119+
format: "{{.Digest}}"
120+
return: "sha256:0123456789abcdef..."
121+
```
122+
123+
### Copy rules
124+
125+
#### Success (no return section)
126+
127+
```yaml
128+
copy:
129+
- match:
130+
source: "docker://quay.io/src:tag"
131+
destination: "docker://quay.io/dest:tag"
132+
# Omit return for success
133+
```
134+
135+
#### Explicit success
136+
137+
```yaml
138+
copy:
139+
- match:
140+
source: "docker://quay.io/src:tag"
141+
destination: "docker://quay.io/dest:tag"
142+
return:
143+
success: true
144+
```
145+
146+
#### Failure
147+
148+
```yaml
149+
copy:
150+
- match:
151+
source: "docker://quay.io/bad:tag"
152+
destination: "docker://quay.io/dest:tag"
153+
return:
154+
success: false
155+
stderr: "Error: manifest unknown"
156+
returncode: 1 # optional, defaults to 1
157+
```
158+
159+
## Matching behavior
160+
161+
### Field matching
162+
163+
- Only fields specified in the `match` section need to match
164+
- Extra parameters in the actual call are ignored
165+
- `None` values don't match specified patterns
166+
- Credentials (`Secret` objects) are always ignored in matching
167+
168+
### Match order
169+
170+
- Rules are evaluated top-to-bottom
171+
- **First matching rule wins**
172+
- Put specific rules before generic catch-all rules
173+
174+
### Regex matching
175+
176+
- Use `{regex: "pattern"}` syntax for regex fields
177+
- Regex must match the **entire string** (`re.fullmatch`)
178+
- Invalid regex patterns cause load-time errors
179+
180+
## Validation
181+
182+
The fake client validates configuration at load time:
183+
184+
- YAML syntax must be valid
185+
- Each rule must have a `match` section
186+
- `inspect` rules with `format` must return strings
187+
- `inspect` rules without `format` must return dicts
188+
- `copy` rules must return dicts (if return section exists)
189+
- Operation names must be valid (`inspect`, `copy`)
190+
191+
## Error handling
192+
193+
When no rule matches, the fake client raises `SkopeoClientError` with:
194+
- Error message: "No mock match found"
195+
- Attempted parameters shown in stderr
196+
- Reference to the config file path
197+
198+
Example error:
199+
```
200+
MOCK ERROR: No matching rule found for inspect()
201+
Attempted with:
202+
image: "docker://quay.io/actual:tag"
203+
format: "{{.Digest}}"
204+
Config file: /path/to/mock-config.yaml
205+
```
206+
207+
## Environment Variables
208+
209+
- **`RELEASE_SERVICE_UTILS_FAKE_SKOPEO_SETUP`** (required): Path to YAML config file
210+
211+
## Running Tests
212+
213+
```bash
214+
# Install test dependencies
215+
pip install pytest pyyaml
216+
217+
# Run the tests
218+
pytest fake/test_fake_skopeo.py -v
219+
```
220+
221+
## Example Use Cases
222+
223+
### Testing successful publish
224+
225+
```yaml
226+
inspect:
227+
- match:
228+
image: "docker://quay.io/target/image:tag"
229+
format: "{{.Digest}}"
230+
return: "sha256:different" # Different from source
231+
232+
copy:
233+
- match:
234+
source: "docker://quay.io/source/image@sha256:abc123"
235+
destination: "docker://quay.io/target/image:tag"
236+
```
237+
238+
### Testing idempotent publish (same digest)
239+
240+
```yaml
241+
inspect:
242+
- match:
243+
image: "docker://quay.io/target/image:tag"
244+
format: "{{.Digest}}"
245+
return: "sha256:abc123" # Same as source - should skip copy
246+
```
247+
248+
### Testing copy failure
249+
250+
```yaml
251+
inspect:
252+
- match:
253+
image: "docker://quay.io/target/image:tag"
254+
format: "{{.Digest}}"
255+
return: "sha256:different"
256+
257+
copy:
258+
- match:
259+
source: "docker://quay.io/source/image@sha256:abc123"
260+
destination: "docker://quay.io/target/image:tag"
261+
return:
262+
success: false
263+
stderr: "Error: authentication required"
264+
```
265+
266+
## See Also
267+
268+
- `example_config.yaml` - Comprehensive example configuration
269+
- `test_fake_skopeo.py` - Test suite demonstrating usage
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Fake implementations for testing."""
2+
3+
from fake.skopeo import FakeSkopeoClient
4+
5+
6+
def patch_skopeo_client():
7+
"""Monkey-patch skopeo.SkopeoClient with FakeSkopeoClient.
8+
9+
This function replaces the real SkopeoClient with the fake implementation.
10+
Must be called before importing any modules that use SkopeoClient.
11+
"""
12+
import skopeo
13+
14+
skopeo.SkopeoClient = FakeSkopeoClient
15+
16+
17+
__all__ = ["FakeSkopeoClient", "patch_skopeo_client"]

0 commit comments

Comments
 (0)