openshift-update-proxy 1.0.0__tar.gz

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.
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.4
2
+ Name: openshift-update-proxy
3
+ Version: 1.0.0
4
+ Summary: Forwarding proxy for OpenShift update resources (Cincinnati API, mirror, release signatures)
5
+ Author-email: Simon Lauger <simon@lauger.de>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Repository, https://github.com/slauger/openshift-update-proxy
8
+ Keywords: openshift,cincinnati,update-graph,proxy,disconnected,air-gapped
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Framework :: Flask
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: System :: Systems Administration
16
+ Requires-Python: >=3.14
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: flask>=3.0.0
20
+ Requires-Dist: requests>=2.31.0
21
+ Requires-Dist: waitress>=3.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
24
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
25
+ Requires-Dist: black>=23.7.0; extra == "dev"
26
+ Requires-Dist: ruff>=0.0.285; extra == "dev"
27
+ Requires-Dist: mypy>=1.5.0; extra == "dev"
28
+ Requires-Dist: types-requests; extra == "dev"
29
+ Requires-Dist: types-waitress; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # 🔄 openshift-update-proxy
33
+
34
+ [![CI](https://github.com/slauger/openshift-update-proxy/actions/workflows/ci.yml/badge.svg)](https://github.com/slauger/openshift-update-proxy/actions/workflows/ci.yml)
35
+ [![Release](https://github.com/slauger/openshift-update-proxy/actions/workflows/release.yml/badge.svg)](https://github.com/slauger/openshift-update-proxy/actions/workflows/release.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/openshift-update-proxy)](https://pypi.org/project/openshift-update-proxy/)
37
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
38
+
39
+ A small Flask based service which forwards HTTP requests to `api.openshift.com` and
40
+ `mirror.openshift.com`. Built for restricted networks where OpenShift clusters have no
41
+ direct internet access, but a central egress proxy (or a single host with internet
42
+ access) exists.
43
+
44
+ ## Features
45
+
46
+ - 🔀 **Update Graph Proxy** - forwards Cincinnati update graph requests
47
+ (`/api/upgrades_info/v1/graph`) to `api.openshift.com`
48
+ - 📦 **Mirror Proxy** - forwards requests for clients and release artifacts to
49
+ `mirror.openshift.com/pub`
50
+ - 🔏 **Signature Store** - serves release image signatures for
51
+ `ClusterVersion.spec.signatureStores` (OpenShift 4.14+)
52
+ - 🗺️ **ConfigMap Generator** - renders ready-to-apply signature ConfigMaps for the
53
+ classic disconnected verification workflow
54
+ - 🚦 **Egress Proxy Aware** - honors `HTTPS_PROXY` / `NO_PROXY` for all upstream requests
55
+ - 🐳 **Hardened Container** - UBI9 based, rootless (UID 1001), digest-pinned base image,
56
+ Cosign signed
57
+ - ⛵ **Helm Chart** - deploy to Kubernetes/OpenShift with probes and sane security defaults
58
+ - 🩺 **Health Endpoint** - `/healthz` for liveness and readiness probes
59
+
60
+ ## How it works
61
+
62
+ ```mermaid
63
+ flowchart LR
64
+ subgraph restricted["Restricted network"]
65
+ CVO["Cluster Version Operator"]
66
+ ADMIN["Admin (oc / curl)"]
67
+ PROXY["openshift-update-proxy"]
68
+ end
69
+
70
+ subgraph internet["Internet"]
71
+ API["api.openshift.com"]
72
+ MIRROR["mirror.openshift.com"]
73
+ end
74
+
75
+ CVO -- "/api/upgrades_info/v1/graph" --> PROXY
76
+ CVO -- "/signatures/sha256=…" --> PROXY
77
+ ADMIN -- "/configmaps/sha256=…" --> PROXY
78
+ ADMIN -- "/pub/…" --> PROXY
79
+
80
+ PROXY -- "optional egress proxy (HTTPS_PROXY)" --> EGRESS["Egress Proxy"]
81
+ EGRESS --> API
82
+ EGRESS --> MIRROR
83
+ ```
84
+
85
+ ## Endpoints
86
+
87
+ | Endpoint | Upstream | Purpose |
88
+ |----------|----------|---------|
89
+ | `/api/<path>` | `https://api.openshift.com/api/` | Cincinnati update graph (`/api/upgrades_info/v1/graph`) |
90
+ | `/pub/<path>` | `https://mirror.openshift.com/pub/` | OpenShift mirror (clients, release artifacts) |
91
+ | `/signatures/<path>` | `https://mirror.openshift.com/pub/openshift-v4/signatures/openshift/release/` | Release image signature store |
92
+ | `/configmaps/sha256=<digest>` | derived from signature store | Ready-to-apply signature ConfigMap (YAML) |
93
+ | `/healthz` | - | Health check for liveness/readiness probes |
94
+
95
+ ## Configuration
96
+
97
+ All configuration is done via environment variables:
98
+
99
+ | Variable | Default | Description |
100
+ |----------|---------|-------------|
101
+ | `HTTPS_PROXY` | - | Egress proxy for upstream requests (standard `requests` behaviour, `NO_PROXY` is honored) |
102
+ | `INSECURE_SKIP_TLS_VERIFY` | `false` | Skip TLS certificate verification for upstream requests (`true`/`1`/`yes`) |
103
+ | `API_UPSTREAM` | `https://api.openshift.com/api/` | Cincinnati API base URL |
104
+ | `MIRROR_UPSTREAM` | `https://mirror.openshift.com/pub/` | Mirror base URL |
105
+ | `SIGNATURE_UPSTREAM` | `https://mirror.openshift.com/pub/openshift-v4/signatures/openshift/release/` | Signature store base URL |
106
+ | `REQUEST_TIMEOUT` | `30` | Upstream request timeout in seconds |
107
+ | `LISTEN_HOST` | `0.0.0.0` | Listen address |
108
+ | `LISTEN_PORT` | `5000` | Listen port |
109
+
110
+ ## Quick Start
111
+
112
+ ### Container
113
+
114
+ ```bash
115
+ docker run --rm -p 5000:5000 \
116
+ -e HTTPS_PROXY=http://proxy.example.com:3128 \
117
+ ghcr.io/slauger/openshift-update-proxy:latest
118
+ ```
119
+
120
+ The image is based on `registry.access.redhat.com/ubi9/python-314`, runs as UID `1001`
121
+ and is built from the `Containerfile` in this repository.
122
+
123
+ ### Helm
124
+
125
+ ```bash
126
+ helm install update-proxy ./chart \
127
+ --set env[0].name=HTTPS_PROXY,env[0].value=http://proxy.example.com:3128
128
+ ```
129
+
130
+ ### PyPI
131
+
132
+ ```bash
133
+ python3 -m venv .venv && source .venv/bin/activate
134
+ pip install openshift-update-proxy
135
+ openshift-update-proxy
136
+ ```
137
+
138
+ ## Cluster integration
139
+
140
+ ### Update graph
141
+
142
+ Point the ClusterVersion `upstream` at the proxy:
143
+
144
+ ```yaml
145
+ apiVersion: config.openshift.io/v1
146
+ kind: ClusterVersion
147
+ metadata:
148
+ name: version
149
+ spec:
150
+ upstream: http://update-proxy.example.com:5050/api/upgrades_info/v1/graph
151
+ ```
152
+
153
+ ### Release signatures
154
+
155
+ For updates by digest (`oc adm upgrade --to-image ...@sha256:...`) the CVO must verify the
156
+ release image signature. There are two ways to get signatures into a restricted cluster:
157
+
158
+ **Option 1: Signature store (OpenShift 4.14+)**
159
+
160
+ Point the cluster at the `/signatures/` endpoint of the proxy:
161
+
162
+ ```yaml
163
+ apiVersion: config.openshift.io/v1
164
+ kind: ClusterVersion
165
+ metadata:
166
+ name: version
167
+ spec:
168
+ signatureStores:
169
+ - url: http://update-proxy.example.com:5050/signatures
170
+ ```
171
+
172
+ **Option 2: Signature ConfigMap**
173
+
174
+ The `/configmaps/` endpoint fetches all signatures for a release digest and renders a
175
+ ready-to-apply ConfigMap (same format as `oc adm release mirror` / oc-mirror produces):
176
+
177
+ ```bash
178
+ DIGEST=$(oc adm release info quay.io/openshift-release-dev/ocp-release:4.16.8-x86_64 -o jsonpath='{.digest}')
179
+ curl -s "http://update-proxy.example.com:5050/configmaps/${DIGEST/:/=}" | oc apply -f -
180
+ ```
181
+
182
+ The ConfigMap is created in `openshift-config-managed` with the
183
+ `release.openshift.io/verification-signatures` label, where the CVO picks it up.
184
+
185
+ ## Local Development
186
+
187
+ ```bash
188
+ python3 -m venv .venv
189
+ source .venv/bin/activate
190
+ pip install -e ".[dev]"
191
+ openshift-update-proxy
192
+ ```
193
+
194
+ Run tests and linting:
195
+
196
+ ```bash
197
+ make test
198
+ make lint
199
+ ```
200
+
201
+ Build the container image:
202
+
203
+ ```bash
204
+ make build
205
+ ```
206
+
207
+ ## Supply Chain Security
208
+
209
+ - The UBI9 base image is pinned by digest and kept up to date by
210
+ [Renovate](https://docs.renovatebot.com/); remaining CVEs are patched at build time via
211
+ `dnf upgrade`.
212
+ - Python and GitHub Actions dependencies are also managed by Renovate (with automerge for
213
+ non-major updates).
214
+ - Releases are fully automated with
215
+ [python-semantic-release](https://python-semantic-release.readthedocs.io/) based on
216
+ Conventional Commits and published to PyPI.
217
+ - Container images are signed with [Cosign](https://github.com/sigstore/cosign) (keyless,
218
+ GitHub Actions OIDC). Verify with:
219
+
220
+ ```bash
221
+ cosign verify \
222
+ --certificate-identity-regexp 'https://github.com/slauger/openshift-update-proxy/.*' \
223
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
224
+ ghcr.io/slauger/openshift-update-proxy:latest
225
+ ```
226
+
227
+ ## License
228
+
229
+ [Apache License 2.0](LICENSE)
@@ -0,0 +1,198 @@
1
+ # 🔄 openshift-update-proxy
2
+
3
+ [![CI](https://github.com/slauger/openshift-update-proxy/actions/workflows/ci.yml/badge.svg)](https://github.com/slauger/openshift-update-proxy/actions/workflows/ci.yml)
4
+ [![Release](https://github.com/slauger/openshift-update-proxy/actions/workflows/release.yml/badge.svg)](https://github.com/slauger/openshift-update-proxy/actions/workflows/release.yml)
5
+ [![PyPI](https://img.shields.io/pypi/v/openshift-update-proxy)](https://pypi.org/project/openshift-update-proxy/)
6
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
7
+
8
+ A small Flask based service which forwards HTTP requests to `api.openshift.com` and
9
+ `mirror.openshift.com`. Built for restricted networks where OpenShift clusters have no
10
+ direct internet access, but a central egress proxy (or a single host with internet
11
+ access) exists.
12
+
13
+ ## Features
14
+
15
+ - 🔀 **Update Graph Proxy** - forwards Cincinnati update graph requests
16
+ (`/api/upgrades_info/v1/graph`) to `api.openshift.com`
17
+ - 📦 **Mirror Proxy** - forwards requests for clients and release artifacts to
18
+ `mirror.openshift.com/pub`
19
+ - 🔏 **Signature Store** - serves release image signatures for
20
+ `ClusterVersion.spec.signatureStores` (OpenShift 4.14+)
21
+ - 🗺️ **ConfigMap Generator** - renders ready-to-apply signature ConfigMaps for the
22
+ classic disconnected verification workflow
23
+ - 🚦 **Egress Proxy Aware** - honors `HTTPS_PROXY` / `NO_PROXY` for all upstream requests
24
+ - 🐳 **Hardened Container** - UBI9 based, rootless (UID 1001), digest-pinned base image,
25
+ Cosign signed
26
+ - ⛵ **Helm Chart** - deploy to Kubernetes/OpenShift with probes and sane security defaults
27
+ - 🩺 **Health Endpoint** - `/healthz` for liveness and readiness probes
28
+
29
+ ## How it works
30
+
31
+ ```mermaid
32
+ flowchart LR
33
+ subgraph restricted["Restricted network"]
34
+ CVO["Cluster Version Operator"]
35
+ ADMIN["Admin (oc / curl)"]
36
+ PROXY["openshift-update-proxy"]
37
+ end
38
+
39
+ subgraph internet["Internet"]
40
+ API["api.openshift.com"]
41
+ MIRROR["mirror.openshift.com"]
42
+ end
43
+
44
+ CVO -- "/api/upgrades_info/v1/graph" --> PROXY
45
+ CVO -- "/signatures/sha256=…" --> PROXY
46
+ ADMIN -- "/configmaps/sha256=…" --> PROXY
47
+ ADMIN -- "/pub/…" --> PROXY
48
+
49
+ PROXY -- "optional egress proxy (HTTPS_PROXY)" --> EGRESS["Egress Proxy"]
50
+ EGRESS --> API
51
+ EGRESS --> MIRROR
52
+ ```
53
+
54
+ ## Endpoints
55
+
56
+ | Endpoint | Upstream | Purpose |
57
+ |----------|----------|---------|
58
+ | `/api/<path>` | `https://api.openshift.com/api/` | Cincinnati update graph (`/api/upgrades_info/v1/graph`) |
59
+ | `/pub/<path>` | `https://mirror.openshift.com/pub/` | OpenShift mirror (clients, release artifacts) |
60
+ | `/signatures/<path>` | `https://mirror.openshift.com/pub/openshift-v4/signatures/openshift/release/` | Release image signature store |
61
+ | `/configmaps/sha256=<digest>` | derived from signature store | Ready-to-apply signature ConfigMap (YAML) |
62
+ | `/healthz` | - | Health check for liveness/readiness probes |
63
+
64
+ ## Configuration
65
+
66
+ All configuration is done via environment variables:
67
+
68
+ | Variable | Default | Description |
69
+ |----------|---------|-------------|
70
+ | `HTTPS_PROXY` | - | Egress proxy for upstream requests (standard `requests` behaviour, `NO_PROXY` is honored) |
71
+ | `INSECURE_SKIP_TLS_VERIFY` | `false` | Skip TLS certificate verification for upstream requests (`true`/`1`/`yes`) |
72
+ | `API_UPSTREAM` | `https://api.openshift.com/api/` | Cincinnati API base URL |
73
+ | `MIRROR_UPSTREAM` | `https://mirror.openshift.com/pub/` | Mirror base URL |
74
+ | `SIGNATURE_UPSTREAM` | `https://mirror.openshift.com/pub/openshift-v4/signatures/openshift/release/` | Signature store base URL |
75
+ | `REQUEST_TIMEOUT` | `30` | Upstream request timeout in seconds |
76
+ | `LISTEN_HOST` | `0.0.0.0` | Listen address |
77
+ | `LISTEN_PORT` | `5000` | Listen port |
78
+
79
+ ## Quick Start
80
+
81
+ ### Container
82
+
83
+ ```bash
84
+ docker run --rm -p 5000:5000 \
85
+ -e HTTPS_PROXY=http://proxy.example.com:3128 \
86
+ ghcr.io/slauger/openshift-update-proxy:latest
87
+ ```
88
+
89
+ The image is based on `registry.access.redhat.com/ubi9/python-314`, runs as UID `1001`
90
+ and is built from the `Containerfile` in this repository.
91
+
92
+ ### Helm
93
+
94
+ ```bash
95
+ helm install update-proxy ./chart \
96
+ --set env[0].name=HTTPS_PROXY,env[0].value=http://proxy.example.com:3128
97
+ ```
98
+
99
+ ### PyPI
100
+
101
+ ```bash
102
+ python3 -m venv .venv && source .venv/bin/activate
103
+ pip install openshift-update-proxy
104
+ openshift-update-proxy
105
+ ```
106
+
107
+ ## Cluster integration
108
+
109
+ ### Update graph
110
+
111
+ Point the ClusterVersion `upstream` at the proxy:
112
+
113
+ ```yaml
114
+ apiVersion: config.openshift.io/v1
115
+ kind: ClusterVersion
116
+ metadata:
117
+ name: version
118
+ spec:
119
+ upstream: http://update-proxy.example.com:5050/api/upgrades_info/v1/graph
120
+ ```
121
+
122
+ ### Release signatures
123
+
124
+ For updates by digest (`oc adm upgrade --to-image ...@sha256:...`) the CVO must verify the
125
+ release image signature. There are two ways to get signatures into a restricted cluster:
126
+
127
+ **Option 1: Signature store (OpenShift 4.14+)**
128
+
129
+ Point the cluster at the `/signatures/` endpoint of the proxy:
130
+
131
+ ```yaml
132
+ apiVersion: config.openshift.io/v1
133
+ kind: ClusterVersion
134
+ metadata:
135
+ name: version
136
+ spec:
137
+ signatureStores:
138
+ - url: http://update-proxy.example.com:5050/signatures
139
+ ```
140
+
141
+ **Option 2: Signature ConfigMap**
142
+
143
+ The `/configmaps/` endpoint fetches all signatures for a release digest and renders a
144
+ ready-to-apply ConfigMap (same format as `oc adm release mirror` / oc-mirror produces):
145
+
146
+ ```bash
147
+ DIGEST=$(oc adm release info quay.io/openshift-release-dev/ocp-release:4.16.8-x86_64 -o jsonpath='{.digest}')
148
+ curl -s "http://update-proxy.example.com:5050/configmaps/${DIGEST/:/=}" | oc apply -f -
149
+ ```
150
+
151
+ The ConfigMap is created in `openshift-config-managed` with the
152
+ `release.openshift.io/verification-signatures` label, where the CVO picks it up.
153
+
154
+ ## Local Development
155
+
156
+ ```bash
157
+ python3 -m venv .venv
158
+ source .venv/bin/activate
159
+ pip install -e ".[dev]"
160
+ openshift-update-proxy
161
+ ```
162
+
163
+ Run tests and linting:
164
+
165
+ ```bash
166
+ make test
167
+ make lint
168
+ ```
169
+
170
+ Build the container image:
171
+
172
+ ```bash
173
+ make build
174
+ ```
175
+
176
+ ## Supply Chain Security
177
+
178
+ - The UBI9 base image is pinned by digest and kept up to date by
179
+ [Renovate](https://docs.renovatebot.com/); remaining CVEs are patched at build time via
180
+ `dnf upgrade`.
181
+ - Python and GitHub Actions dependencies are also managed by Renovate (with automerge for
182
+ non-major updates).
183
+ - Releases are fully automated with
184
+ [python-semantic-release](https://python-semantic-release.readthedocs.io/) based on
185
+ Conventional Commits and published to PyPI.
186
+ - Container images are signed with [Cosign](https://github.com/sigstore/cosign) (keyless,
187
+ GitHub Actions OIDC). Verify with:
188
+
189
+ ```bash
190
+ cosign verify \
191
+ --certificate-identity-regexp 'https://github.com/slauger/openshift-update-proxy/.*' \
192
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
193
+ ghcr.io/slauger/openshift-update-proxy:latest
194
+ ```
195
+
196
+ ## License
197
+
198
+ [Apache License 2.0](LICENSE)
@@ -0,0 +1,95 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openshift-update-proxy"
7
+ version = "1.0.0"
8
+ description = "Forwarding proxy for OpenShift update resources (Cincinnati API, mirror, release signatures)"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.14"
12
+ authors = [
13
+ {name = "Simon Lauger", email = "simon@lauger.de"},
14
+ ]
15
+ keywords = ["openshift", "cincinnati", "update-graph", "proxy", "disconnected", "air-gapped"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Web Environment",
19
+ "Framework :: Flask",
20
+ "Intended Audience :: System Administrators",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: System :: Systems Administration",
24
+ ]
25
+ dependencies = [
26
+ "flask>=3.0.0",
27
+ "requests>=2.31.0",
28
+ "waitress>=3.0.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=7.4.0",
34
+ "pytest-cov>=4.1.0",
35
+ "black>=23.7.0",
36
+ "ruff>=0.0.285",
37
+ "mypy>=1.5.0",
38
+ "types-requests",
39
+ "types-waitress",
40
+ ]
41
+
42
+ [project.scripts]
43
+ openshift-update-proxy = "openshift_update_proxy.__main__:main"
44
+
45
+ [project.urls]
46
+ Repository = "https://github.com/slauger/openshift-update-proxy"
47
+
48
+ [tool.setuptools.packages.find]
49
+ where = ["src"]
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ target-version = "py314"
54
+
55
+ [tool.ruff.lint]
56
+ select = [
57
+ "E", # pycodestyle errors
58
+ "W", # pycodestyle warnings
59
+ "F", # pyflakes
60
+ "I", # isort
61
+ "B", # flake8-bugbear
62
+ "C4", # flake8-comprehensions
63
+ ]
64
+
65
+ [tool.black]
66
+ line-length = 100
67
+ target-version = ["py314"]
68
+
69
+ [tool.mypy]
70
+ python_version = "3.14"
71
+ warn_return_any = true
72
+ warn_unused_configs = true
73
+ no_implicit_optional = true
74
+ warn_redundant_casts = true
75
+
76
+ [tool.pytest.ini_options]
77
+ testpaths = ["tests"]
78
+ python_files = ["test_*.py"]
79
+ python_classes = ["Test*"]
80
+ python_functions = ["test_*"]
81
+
82
+ [tool.semantic_release]
83
+ version_toml = ["pyproject.toml:project.version"]
84
+ version_variables = [
85
+ "src/openshift_update_proxy/__init__.py:__version__",
86
+ ]
87
+ branch = "master"
88
+ build_command = "pip install build && python -m build"
89
+ commit_parser = "angular"
90
+ allow_zero_version = false
91
+
92
+ [tool.semantic_release.commit_parser_options]
93
+ allowed_tags = ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert"]
94
+ minor_tags = ["feat"]
95
+ patch_tags = ["fix", "perf"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """OpenShift update proxy for disconnected environments."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,27 @@
1
+ """Entry point running the application with waitress."""
2
+
3
+ import logging
4
+
5
+ from waitress import serve
6
+
7
+ from openshift_update_proxy.app import create_app
8
+ from openshift_update_proxy.config import Config
9
+
10
+
11
+ def main() -> None:
12
+ logging.basicConfig(
13
+ level=logging.INFO,
14
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
15
+ )
16
+
17
+ config = Config()
18
+ app = create_app(config)
19
+
20
+ logging.getLogger("openshift-update-proxy").info(
21
+ "listening on %s:%d", config.listen_host, config.listen_port
22
+ )
23
+ serve(app, host=config.listen_host, port=config.listen_port)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
@@ -0,0 +1,138 @@
1
+ """Flask application serving as a forwarding proxy for OpenShift update resources."""
2
+
3
+ import base64
4
+ import logging
5
+ import re
6
+
7
+ import requests
8
+ from flask import Flask, Response, jsonify, request
9
+
10
+ from openshift_update_proxy import __version__
11
+ from openshift_update_proxy.config import Config
12
+
13
+ logger = logging.getLogger("openshift-update-proxy")
14
+
15
+ # hop-by-hop and encoding-related headers must not be copied to the client,
16
+ # as the payload is re-encoded by the WSGI server
17
+ EXCLUDED_HEADERS = {
18
+ "connection",
19
+ "content-encoding",
20
+ "content-length",
21
+ "keep-alive",
22
+ "proxy-authenticate",
23
+ "proxy-authorization",
24
+ "te",
25
+ "trailers",
26
+ "transfer-encoding",
27
+ "upgrade",
28
+ }
29
+
30
+ DIGEST_PATTERN = re.compile(r"^(sha256[:=])?(?P<digest>[0-9a-f]{64})$")
31
+
32
+ CONFIGMAP_TEMPLATE = """apiVersion: v1
33
+ kind: ConfigMap
34
+ metadata:
35
+ name: signature-sha256-{short_digest}
36
+ namespace: openshift-config-managed
37
+ labels:
38
+ release.openshift.io/verification-signatures: ""
39
+ binaryData:
40
+ {binary_data}"""
41
+
42
+
43
+ def create_app(config: Config | None = None) -> Flask:
44
+ app = Flask(__name__)
45
+ app.config["proxy"] = config or Config()
46
+
47
+ @app.route("/")
48
+ def index() -> Response:
49
+ return jsonify(
50
+ {
51
+ "name": "openshift-update-proxy",
52
+ "version": __version__,
53
+ "endpoints": ["/api/", "/pub/", "/signatures/", "/configmaps/", "/healthz"],
54
+ }
55
+ )
56
+
57
+ @app.route("/healthz")
58
+ def healthz() -> Response:
59
+ return jsonify({"status": "ok"})
60
+
61
+ @app.route("/api/<path:path>")
62
+ def api_proxy(path: str) -> Response:
63
+ cfg = app.config["proxy"]
64
+ return _forward(cfg, f"{cfg.api_upstream}/{path}", params=request.args)
65
+
66
+ @app.route("/pub/<path:path>")
67
+ def mirror_proxy(path: str) -> Response:
68
+ cfg = app.config["proxy"]
69
+ return _forward(cfg, f"{cfg.mirror_upstream}/{path}")
70
+
71
+ @app.route("/signatures/<path:path>")
72
+ def signature_proxy(path: str) -> Response:
73
+ cfg = app.config["proxy"]
74
+ return _forward(cfg, f"{cfg.signature_upstream}/{path}")
75
+
76
+ @app.route("/configmaps/<digest>")
77
+ def signature_configmap(digest: str) -> Response:
78
+ cfg = app.config["proxy"]
79
+
80
+ match = DIGEST_PATTERN.match(digest)
81
+ if not match:
82
+ return Response(
83
+ "invalid digest, expected sha256=<64 hex chars>\n",
84
+ status=400,
85
+ mimetype="text/plain",
86
+ )
87
+
88
+ signatures = _fetch_signatures(cfg, match.group("digest"))
89
+ if not signatures:
90
+ return Response("no signatures found for digest\n", status=404, mimetype="text/plain")
91
+
92
+ return Response(
93
+ _render_configmap(match.group("digest"), signatures),
94
+ mimetype="application/yaml",
95
+ )
96
+
97
+ return app
98
+
99
+
100
+ def _forward(cfg: Config, url: str, params: dict | None = None) -> Response:
101
+ logger.info("forwarding request from source %s to upstream %s", request.remote_addr, url)
102
+
103
+ upstream = requests.get(
104
+ url,
105
+ params=params,
106
+ verify=cfg.ssl_verify,
107
+ timeout=cfg.request_timeout,
108
+ )
109
+
110
+ headers = {
111
+ key: value for key, value in upstream.headers.items() if key.lower() not in EXCLUDED_HEADERS
112
+ }
113
+
114
+ return Response(upstream.content, status=upstream.status_code, headers=headers)
115
+
116
+
117
+ def _fetch_signatures(cfg: Config, digest: str, limit: int = 10) -> list[bytes]:
118
+ signatures = []
119
+
120
+ for index in range(1, limit + 1):
121
+ url = f"{cfg.signature_upstream}/sha256={digest}/signature-{index}"
122
+ response = requests.get(url, verify=cfg.ssl_verify, timeout=cfg.request_timeout)
123
+
124
+ if response.status_code != 200:
125
+ break
126
+
127
+ signatures.append(response.content)
128
+
129
+ return signatures
130
+
131
+
132
+ def _render_configmap(digest: str, signatures: list[bytes]) -> str:
133
+ binary_data = "\n".join(
134
+ f" sha256-{digest}-{index}: {base64.b64encode(signature).decode('ascii')}"
135
+ for index, signature in enumerate(signatures, start=1)
136
+ )
137
+
138
+ return CONFIGMAP_TEMPLATE.format(short_digest=digest[:16], binary_data=binary_data)
@@ -0,0 +1,23 @@
1
+ """Configuration from environment variables."""
2
+
3
+ import os
4
+
5
+ TRUTHY = ("1", "true", "yes", "on")
6
+
7
+
8
+ class Config:
9
+ def __init__(self) -> None:
10
+ self.api_upstream = os.environ.get("API_UPSTREAM", "https://api.openshift.com/api/").rstrip(
11
+ "/"
12
+ )
13
+ self.mirror_upstream = os.environ.get(
14
+ "MIRROR_UPSTREAM", "https://mirror.openshift.com/pub/"
15
+ ).rstrip("/")
16
+ self.signature_upstream = os.environ.get(
17
+ "SIGNATURE_UPSTREAM",
18
+ "https://mirror.openshift.com/pub/openshift-v4/signatures/openshift/release/",
19
+ ).rstrip("/")
20
+ self.ssl_verify = os.environ.get("INSECURE_SKIP_TLS_VERIFY", "").lower() not in TRUTHY
21
+ self.request_timeout = float(os.environ.get("REQUEST_TIMEOUT", "30"))
22
+ self.listen_host = os.environ.get("LISTEN_HOST", "0.0.0.0")
23
+ self.listen_port = int(os.environ.get("LISTEN_PORT", "5000"))
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.4
2
+ Name: openshift-update-proxy
3
+ Version: 1.0.0
4
+ Summary: Forwarding proxy for OpenShift update resources (Cincinnati API, mirror, release signatures)
5
+ Author-email: Simon Lauger <simon@lauger.de>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Repository, https://github.com/slauger/openshift-update-proxy
8
+ Keywords: openshift,cincinnati,update-graph,proxy,disconnected,air-gapped
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Framework :: Flask
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: System :: Systems Administration
16
+ Requires-Python: >=3.14
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: flask>=3.0.0
20
+ Requires-Dist: requests>=2.31.0
21
+ Requires-Dist: waitress>=3.0.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
24
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
25
+ Requires-Dist: black>=23.7.0; extra == "dev"
26
+ Requires-Dist: ruff>=0.0.285; extra == "dev"
27
+ Requires-Dist: mypy>=1.5.0; extra == "dev"
28
+ Requires-Dist: types-requests; extra == "dev"
29
+ Requires-Dist: types-waitress; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # 🔄 openshift-update-proxy
33
+
34
+ [![CI](https://github.com/slauger/openshift-update-proxy/actions/workflows/ci.yml/badge.svg)](https://github.com/slauger/openshift-update-proxy/actions/workflows/ci.yml)
35
+ [![Release](https://github.com/slauger/openshift-update-proxy/actions/workflows/release.yml/badge.svg)](https://github.com/slauger/openshift-update-proxy/actions/workflows/release.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/openshift-update-proxy)](https://pypi.org/project/openshift-update-proxy/)
37
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
38
+
39
+ A small Flask based service which forwards HTTP requests to `api.openshift.com` and
40
+ `mirror.openshift.com`. Built for restricted networks where OpenShift clusters have no
41
+ direct internet access, but a central egress proxy (or a single host with internet
42
+ access) exists.
43
+
44
+ ## Features
45
+
46
+ - 🔀 **Update Graph Proxy** - forwards Cincinnati update graph requests
47
+ (`/api/upgrades_info/v1/graph`) to `api.openshift.com`
48
+ - 📦 **Mirror Proxy** - forwards requests for clients and release artifacts to
49
+ `mirror.openshift.com/pub`
50
+ - 🔏 **Signature Store** - serves release image signatures for
51
+ `ClusterVersion.spec.signatureStores` (OpenShift 4.14+)
52
+ - 🗺️ **ConfigMap Generator** - renders ready-to-apply signature ConfigMaps for the
53
+ classic disconnected verification workflow
54
+ - 🚦 **Egress Proxy Aware** - honors `HTTPS_PROXY` / `NO_PROXY` for all upstream requests
55
+ - 🐳 **Hardened Container** - UBI9 based, rootless (UID 1001), digest-pinned base image,
56
+ Cosign signed
57
+ - ⛵ **Helm Chart** - deploy to Kubernetes/OpenShift with probes and sane security defaults
58
+ - 🩺 **Health Endpoint** - `/healthz` for liveness and readiness probes
59
+
60
+ ## How it works
61
+
62
+ ```mermaid
63
+ flowchart LR
64
+ subgraph restricted["Restricted network"]
65
+ CVO["Cluster Version Operator"]
66
+ ADMIN["Admin (oc / curl)"]
67
+ PROXY["openshift-update-proxy"]
68
+ end
69
+
70
+ subgraph internet["Internet"]
71
+ API["api.openshift.com"]
72
+ MIRROR["mirror.openshift.com"]
73
+ end
74
+
75
+ CVO -- "/api/upgrades_info/v1/graph" --> PROXY
76
+ CVO -- "/signatures/sha256=…" --> PROXY
77
+ ADMIN -- "/configmaps/sha256=…" --> PROXY
78
+ ADMIN -- "/pub/…" --> PROXY
79
+
80
+ PROXY -- "optional egress proxy (HTTPS_PROXY)" --> EGRESS["Egress Proxy"]
81
+ EGRESS --> API
82
+ EGRESS --> MIRROR
83
+ ```
84
+
85
+ ## Endpoints
86
+
87
+ | Endpoint | Upstream | Purpose |
88
+ |----------|----------|---------|
89
+ | `/api/<path>` | `https://api.openshift.com/api/` | Cincinnati update graph (`/api/upgrades_info/v1/graph`) |
90
+ | `/pub/<path>` | `https://mirror.openshift.com/pub/` | OpenShift mirror (clients, release artifacts) |
91
+ | `/signatures/<path>` | `https://mirror.openshift.com/pub/openshift-v4/signatures/openshift/release/` | Release image signature store |
92
+ | `/configmaps/sha256=<digest>` | derived from signature store | Ready-to-apply signature ConfigMap (YAML) |
93
+ | `/healthz` | - | Health check for liveness/readiness probes |
94
+
95
+ ## Configuration
96
+
97
+ All configuration is done via environment variables:
98
+
99
+ | Variable | Default | Description |
100
+ |----------|---------|-------------|
101
+ | `HTTPS_PROXY` | - | Egress proxy for upstream requests (standard `requests` behaviour, `NO_PROXY` is honored) |
102
+ | `INSECURE_SKIP_TLS_VERIFY` | `false` | Skip TLS certificate verification for upstream requests (`true`/`1`/`yes`) |
103
+ | `API_UPSTREAM` | `https://api.openshift.com/api/` | Cincinnati API base URL |
104
+ | `MIRROR_UPSTREAM` | `https://mirror.openshift.com/pub/` | Mirror base URL |
105
+ | `SIGNATURE_UPSTREAM` | `https://mirror.openshift.com/pub/openshift-v4/signatures/openshift/release/` | Signature store base URL |
106
+ | `REQUEST_TIMEOUT` | `30` | Upstream request timeout in seconds |
107
+ | `LISTEN_HOST` | `0.0.0.0` | Listen address |
108
+ | `LISTEN_PORT` | `5000` | Listen port |
109
+
110
+ ## Quick Start
111
+
112
+ ### Container
113
+
114
+ ```bash
115
+ docker run --rm -p 5000:5000 \
116
+ -e HTTPS_PROXY=http://proxy.example.com:3128 \
117
+ ghcr.io/slauger/openshift-update-proxy:latest
118
+ ```
119
+
120
+ The image is based on `registry.access.redhat.com/ubi9/python-314`, runs as UID `1001`
121
+ and is built from the `Containerfile` in this repository.
122
+
123
+ ### Helm
124
+
125
+ ```bash
126
+ helm install update-proxy ./chart \
127
+ --set env[0].name=HTTPS_PROXY,env[0].value=http://proxy.example.com:3128
128
+ ```
129
+
130
+ ### PyPI
131
+
132
+ ```bash
133
+ python3 -m venv .venv && source .venv/bin/activate
134
+ pip install openshift-update-proxy
135
+ openshift-update-proxy
136
+ ```
137
+
138
+ ## Cluster integration
139
+
140
+ ### Update graph
141
+
142
+ Point the ClusterVersion `upstream` at the proxy:
143
+
144
+ ```yaml
145
+ apiVersion: config.openshift.io/v1
146
+ kind: ClusterVersion
147
+ metadata:
148
+ name: version
149
+ spec:
150
+ upstream: http://update-proxy.example.com:5050/api/upgrades_info/v1/graph
151
+ ```
152
+
153
+ ### Release signatures
154
+
155
+ For updates by digest (`oc adm upgrade --to-image ...@sha256:...`) the CVO must verify the
156
+ release image signature. There are two ways to get signatures into a restricted cluster:
157
+
158
+ **Option 1: Signature store (OpenShift 4.14+)**
159
+
160
+ Point the cluster at the `/signatures/` endpoint of the proxy:
161
+
162
+ ```yaml
163
+ apiVersion: config.openshift.io/v1
164
+ kind: ClusterVersion
165
+ metadata:
166
+ name: version
167
+ spec:
168
+ signatureStores:
169
+ - url: http://update-proxy.example.com:5050/signatures
170
+ ```
171
+
172
+ **Option 2: Signature ConfigMap**
173
+
174
+ The `/configmaps/` endpoint fetches all signatures for a release digest and renders a
175
+ ready-to-apply ConfigMap (same format as `oc adm release mirror` / oc-mirror produces):
176
+
177
+ ```bash
178
+ DIGEST=$(oc adm release info quay.io/openshift-release-dev/ocp-release:4.16.8-x86_64 -o jsonpath='{.digest}')
179
+ curl -s "http://update-proxy.example.com:5050/configmaps/${DIGEST/:/=}" | oc apply -f -
180
+ ```
181
+
182
+ The ConfigMap is created in `openshift-config-managed` with the
183
+ `release.openshift.io/verification-signatures` label, where the CVO picks it up.
184
+
185
+ ## Local Development
186
+
187
+ ```bash
188
+ python3 -m venv .venv
189
+ source .venv/bin/activate
190
+ pip install -e ".[dev]"
191
+ openshift-update-proxy
192
+ ```
193
+
194
+ Run tests and linting:
195
+
196
+ ```bash
197
+ make test
198
+ make lint
199
+ ```
200
+
201
+ Build the container image:
202
+
203
+ ```bash
204
+ make build
205
+ ```
206
+
207
+ ## Supply Chain Security
208
+
209
+ - The UBI9 base image is pinned by digest and kept up to date by
210
+ [Renovate](https://docs.renovatebot.com/); remaining CVEs are patched at build time via
211
+ `dnf upgrade`.
212
+ - Python and GitHub Actions dependencies are also managed by Renovate (with automerge for
213
+ non-major updates).
214
+ - Releases are fully automated with
215
+ [python-semantic-release](https://python-semantic-release.readthedocs.io/) based on
216
+ Conventional Commits and published to PyPI.
217
+ - Container images are signed with [Cosign](https://github.com/sigstore/cosign) (keyless,
218
+ GitHub Actions OIDC). Verify with:
219
+
220
+ ```bash
221
+ cosign verify \
222
+ --certificate-identity-regexp 'https://github.com/slauger/openshift-update-proxy/.*' \
223
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
224
+ ghcr.io/slauger/openshift-update-proxy:latest
225
+ ```
226
+
227
+ ## License
228
+
229
+ [Apache License 2.0](LICENSE)
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/openshift_update_proxy/__init__.py
5
+ src/openshift_update_proxy/__main__.py
6
+ src/openshift_update_proxy/app.py
7
+ src/openshift_update_proxy/config.py
8
+ src/openshift_update_proxy.egg-info/PKG-INFO
9
+ src/openshift_update_proxy.egg-info/SOURCES.txt
10
+ src/openshift_update_proxy.egg-info/dependency_links.txt
11
+ src/openshift_update_proxy.egg-info/entry_points.txt
12
+ src/openshift_update_proxy.egg-info/requires.txt
13
+ src/openshift_update_proxy.egg-info/top_level.txt
14
+ tests/test_app.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ openshift-update-proxy = openshift_update_proxy.__main__:main
@@ -0,0 +1,12 @@
1
+ flask>=3.0.0
2
+ requests>=2.31.0
3
+ waitress>=3.0.0
4
+
5
+ [dev]
6
+ pytest>=7.4.0
7
+ pytest-cov>=4.1.0
8
+ black>=23.7.0
9
+ ruff>=0.0.285
10
+ mypy>=1.5.0
11
+ types-requests
12
+ types-waitress
@@ -0,0 +1,153 @@
1
+ import base64
2
+
3
+ from conftest import FakeResponse
4
+
5
+ import openshift_update_proxy.app as app_module
6
+
7
+ DIGEST = "a" * 64
8
+
9
+
10
+ def test_index(client):
11
+ response = client.get("/")
12
+
13
+ assert response.status_code == 200
14
+ assert response.json["name"] == "openshift-update-proxy"
15
+
16
+
17
+ def test_healthz(client):
18
+ response = client.get("/healthz")
19
+
20
+ assert response.status_code == 200
21
+ assert response.json == {"status": "ok"}
22
+
23
+
24
+ def test_api_proxy_forwards_url_and_params(client, monkeypatch):
25
+ captured = {}
26
+
27
+ def fake_get(url, params=None, **kwargs):
28
+ captured["url"] = url
29
+ captured["params"] = params
30
+ return FakeResponse(b'{"nodes": []}', headers={"Content-Type": "application/json"})
31
+
32
+ monkeypatch.setattr(app_module.requests, "get", fake_get)
33
+
34
+ response = client.get("/api/upgrades_info/v1/graph?channel=stable-4.16&arch=amd64")
35
+
36
+ assert response.status_code == 200
37
+ assert captured["url"] == "https://api.openshift.com/api/upgrades_info/v1/graph"
38
+ assert captured["params"]["channel"] == "stable-4.16"
39
+ assert captured["params"]["arch"] == "amd64"
40
+ assert response.content_type == "application/json"
41
+
42
+
43
+ def test_api_proxy_preserves_upstream_status(client, monkeypatch):
44
+ monkeypatch.setattr(
45
+ app_module.requests, "get", lambda *a, **kw: FakeResponse(b"not found", 404)
46
+ )
47
+
48
+ response = client.get("/api/does/not/exist")
49
+
50
+ assert response.status_code == 404
51
+ assert response.data == b"not found"
52
+
53
+
54
+ def test_mirror_proxy_forwards_binary_content(client, monkeypatch):
55
+ payload = bytes(range(256))
56
+ captured = {}
57
+
58
+ def fake_get(url, **kwargs):
59
+ captured["url"] = url
60
+ return FakeResponse(payload, headers={"Content-Type": "application/octet-stream"})
61
+
62
+ monkeypatch.setattr(app_module.requests, "get", fake_get)
63
+
64
+ response = client.get("/pub/openshift-v4/clients/ocp/latest/sha256sum.txt")
65
+
66
+ assert response.status_code == 200
67
+ assert captured["url"] == (
68
+ "https://mirror.openshift.com/pub/openshift-v4/clients/ocp/latest/sha256sum.txt"
69
+ )
70
+ assert response.data == payload
71
+
72
+
73
+ def test_signature_proxy(client, monkeypatch):
74
+ captured = {}
75
+
76
+ def fake_get(url, **kwargs):
77
+ captured["url"] = url
78
+ return FakeResponse(b"signature-blob")
79
+
80
+ monkeypatch.setattr(app_module.requests, "get", fake_get)
81
+
82
+ response = client.get(f"/signatures/sha256={DIGEST}/signature-1")
83
+
84
+ assert response.status_code == 200
85
+ assert captured["url"] == (
86
+ "https://mirror.openshift.com/pub/openshift-v4/signatures/openshift/release/"
87
+ f"sha256={DIGEST}/signature-1"
88
+ )
89
+ assert response.data == b"signature-blob"
90
+
91
+
92
+ def test_configmap_rejects_invalid_digest(client):
93
+ response = client.get("/configmaps/not-a-digest")
94
+
95
+ assert response.status_code == 400
96
+
97
+
98
+ def test_configmap_returns_404_without_signatures(client, monkeypatch):
99
+ monkeypatch.setattr(app_module.requests, "get", lambda *a, **kw: FakeResponse(b"", 404))
100
+
101
+ response = client.get(f"/configmaps/sha256={DIGEST}")
102
+
103
+ assert response.status_code == 404
104
+
105
+
106
+ def test_configmap_renders_all_signatures(client, monkeypatch):
107
+ signatures = {
108
+ f"sha256={DIGEST}/signature-1": b"first",
109
+ f"sha256={DIGEST}/signature-2": b"second",
110
+ }
111
+
112
+ def fake_get(url, **kwargs):
113
+ for suffix, content in signatures.items():
114
+ if url.endswith(suffix):
115
+ return FakeResponse(content)
116
+ return FakeResponse(b"", 404)
117
+
118
+ monkeypatch.setattr(app_module.requests, "get", fake_get)
119
+
120
+ response = client.get(f"/configmaps/sha256={DIGEST}")
121
+
122
+ assert response.status_code == 200
123
+ body = response.data.decode()
124
+ assert f"name: signature-sha256-{DIGEST[:16]}" in body
125
+ assert "namespace: openshift-config-managed" in body
126
+ assert 'release.openshift.io/verification-signatures: ""' in body
127
+ assert f"sha256-{DIGEST}-1: {base64.b64encode(b'first').decode()}" in body
128
+ assert f"sha256-{DIGEST}-2: {base64.b64encode(b'second').decode()}" in body
129
+
130
+
131
+ def test_configmap_accepts_bare_digest(client, monkeypatch):
132
+ def fake_get(url, **kwargs):
133
+ if url.endswith("signature-1"):
134
+ return FakeResponse(b"sig")
135
+ return FakeResponse(b"", 404)
136
+
137
+ monkeypatch.setattr(app_module.requests, "get", fake_get)
138
+
139
+ response = client.get(f"/configmaps/{DIGEST}")
140
+
141
+ assert response.status_code == 200
142
+
143
+
144
+ def test_ssl_verify_enabled_by_default(config):
145
+ assert config.ssl_verify is True
146
+
147
+
148
+ def test_ssl_verify_disabled_via_env(monkeypatch):
149
+ from openshift_update_proxy.config import Config
150
+
151
+ monkeypatch.setenv("INSECURE_SKIP_TLS_VERIFY", "true")
152
+
153
+ assert Config().ssl_verify is False