Skip to content

Commit 3d971df

Browse files
committed
feat: add a new source type named "coreos"
This commit adds a new source type named "coreos" which is capable of fetching push items from the openshift installer JSON in GitHub. With this, it's possible to yield `AMIPushItem` and `VHDPushItem` from the installer, as shown in the example below: ``` pushsource-ls "coreos:https://github.com/openshift/installer/tree/release-4.22?paths=data/data/coreos/coreos-rhel-10.json" ``` The new entrypoint also support multiple paths, as shown below: ``` pushsource-ls "coreos:https://github.com/openshift/installer/tree/release-4.22?paths=data/data/coreos/coreos-rhel-9.json,data/data/coreos/coreos-rhel-10.json" ``` This is part of an effort to unblock the releases of new OpenShift versions (greater than v4.19) Signed-off-by: Jonathan Gangi <jgangi@redhat.com> Assisted-by: Cursor
1 parent 811f276 commit 3d971df

11 files changed

Lines changed: 2365 additions & 0 deletions

File tree

docs/sources/coreos.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
Source: coreos
2+
==============
3+
4+
The ``coreos`` push source allows the loading of content from a JSON file of OpenShift CoreOS installer from GitHub.
5+
6+
Supported content types:
7+
8+
* AMIs
9+
* VHDs
10+
11+
12+
coreos source URLs
13+
------------------
14+
15+
The base form of an coreos source URL is:
16+
17+
``coreos:github-url?paths=path1[,path2[,...]]``
18+
19+
For example, retrieving the JSON files for the RHEL 9 and RHEL 10 CoreOS installer would look like:
20+
21+
``coreos:https://github.com/openshift/installer/tree/master?paths=data/coreos-rhel-9.json,data/coreos-rhel-10.json``
22+
23+
The provided GitHub URL should be the base URL of the repository,
24+
and should contain the tag or branch name of the repository.
25+
26+
Python API reference
27+
--------------------
28+
29+
.. autoclass:: pushsource.CoreOSSource
30+
:members:
31+
:special-members: __init__

src/pushsource/_impl/backend/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
from .registry_source import RegistrySource
66
from .direct import DirectSource
77
from .pub_source import PubSource
8+
from .coreos_source import CoreOSSource
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .coreos_source import CoreOSSource
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import json
2+
import logging
3+
import os
4+
import re
5+
import threading
6+
from urllib.parse import urlparse, urlunparse
7+
8+
9+
import requests
10+
from more_executors import Executors
11+
12+
LOG = logging.getLogger("pushsource.coreos_client")
13+
14+
15+
class CoreOSClient(object):
16+
def __init__(self, url, threads=4, timeout=60 * 60, **retry_args):
17+
self._executor = (
18+
Executors.thread_pool(name="pushsource-coreos-client", max_workers=threads)
19+
.with_map(self._unpack_response)
20+
.with_retry(**retry_args)
21+
.with_timeout(timeout)
22+
.with_cancel_on_shutdown()
23+
)
24+
self._url = url
25+
self._tls = threading.local()
26+
27+
def shutdown(self):
28+
self._executor.shutdown(True)
29+
30+
def _unpack_response(self, response):
31+
try:
32+
response.raise_for_status()
33+
out = response.json()
34+
except json.JSONDecodeError:
35+
# do not treat invalid json as fatal error
36+
out = None
37+
return out
38+
39+
@property
40+
def _session(self):
41+
if not hasattr(self._tls, "session"):
42+
LOG.debug(
43+
"Creating requests Session for client of CoreOS service: %s", self._url
44+
)
45+
self._tls.session = requests.Session()
46+
return self._tls.session
47+
48+
def _do_request(self, method, url):
49+
return self._session.request(method, url)
50+
51+
@staticmethod
52+
def convert_github_blob_to_raw(url, path):
53+
"""Convert github.com/.../blob/... to raw.githubusercontent.com URL."""
54+
_GITHUB_BLOB_RE = re.compile(
55+
r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/(blob|tree)/(?P<ref>[^/]+)(?:/(?P<path>.+))?$"
56+
)
57+
58+
# Strip query params and fragment from the URL
59+
full_url = os.path.join(url, path)
60+
parsed_url = urlparse(full_url)
61+
clean_url = urlunparse(parsed_url._replace(query="", fragment=""))
62+
63+
m = _GITHUB_BLOB_RE.match(clean_url.rstrip("/"))
64+
if not m:
65+
return clean_url # already raw or non-GitHub
66+
d = m.groupdict()
67+
68+
# Use the shorter raw form which handles branches, tags, and commit SHAs
69+
# without needing to distinguish between refs/heads or refs/tags
70+
raw_url = f"https://raw.githubusercontent.com/{d['owner']}/{d['repo']}/{d['ref']}/{d['path']}"
71+
LOG.info("Converted URL %s to %s", parsed_url, raw_url)
72+
return raw_url
73+
74+
def get_json_f(self, path):
75+
"""Returns Future[dict|list] holding json obj with VMI push items returned from CoreOS installer file.
76+
77+
Parameters:
78+
path (str)
79+
Path to the JSON file containing the CoreOS installer information.
80+
Eg.: "data/data/coreos/rhcos.json"
81+
"""
82+
url = self.convert_github_blob_to_raw(self._url, path)
83+
LOG.info("Fetching VMI push items from CoreOS installer file: %s", url)
84+
ft = self._executor.submit(self._do_request, method="GET", url=url)
85+
return ft
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
from concurrent import futures
2+
3+
from datetime import datetime
4+
from more_executors import Executors
5+
6+
from ...source import Source
7+
from ...helpers import (
8+
force_https,
9+
as_completed_with_timeout_reset,
10+
list_argument,
11+
try_int,
12+
)
13+
from ...model import AmiPushItem, VHDPushItem, KojiBuildInfo
14+
from .coreos_client import CoreOSClient
15+
16+
17+
class CoreOSSource(Source):
18+
"""Source for VMI push items loaded from CoreOS installer file (JSON)."""
19+
20+
def __init__(
21+
self,
22+
url,
23+
paths,
24+
threads=4,
25+
timeout=60 * 60,
26+
):
27+
"""Create a new source.
28+
29+
Parameters:
30+
url (str)
31+
Base URL of for the GitHub repository containing the CoreOS installer information.
32+
It should contain as well the tag or branch name of the repository.
33+
Eg.: https://github.com/openshift/installer/tree/master
34+
paths (list[str])
35+
List of paths to the JSON files containing the CoreOS installer information.
36+
Eg.: ["data/data/coreos/rhcos.json", "data/data/coreos/rhcos-rhel-10.json"]
37+
threads (int)
38+
Number of threads used for concurrent queries to the CoreOS installer file.
39+
timeout (int)
40+
Number of seconds after which an error is raised, if no progress is
41+
made during queries to the CoreOS installer file.
42+
"""
43+
self._executor = Executors.thread_pool(
44+
name="pushsource-coreos", max_workers=threads
45+
).with_cancel_on_shutdown()
46+
self._url = force_https(url)
47+
self._client = CoreOSClient(url=self._url, threads=threads)
48+
self._paths = list_argument(paths)
49+
self._timeout = timeout
50+
51+
def _push_items_from_json(self, json_obj):
52+
product_stream = json_obj.get("stream") or ""
53+
product_metadata = json_obj.get("metadata") or {}
54+
# Get the product name and version from the stream
55+
name, version = product_stream.split("-")
56+
57+
# Get the release date from the metadata
58+
release_date = product_metadata.get("last-modified") or ""
59+
release_date = datetime.strptime(release_date, "%Y-%m-%dT%H:%M:%SZ")
60+
out = []
61+
vms_with_custom_meta_data = []
62+
architectures = json_obj.get("architectures") or {}
63+
for arch, arch_data in architectures.items():
64+
images = arch_data.get("images") or {}
65+
for cloud_name, image_data in images.items():
66+
if cloud_name == "aws":
67+
# For AWS each region has a different AMI
68+
regions = image_data.get("regions") or {}
69+
for region, region_data in regions.items():
70+
vms_with_custom_meta_data.append(
71+
{
72+
"marketplace_name": "aws",
73+
"push_item_class": AmiPushItem,
74+
"architecture": arch,
75+
"release": region_data.get("release"),
76+
"custom_meta_data": {
77+
"region": region,
78+
"src": region_data.get("image"),
79+
},
80+
}
81+
)
82+
# Azure images are generally in the "rhel-coreos-extensions" section
83+
extensions = arch_data.get("rhel-coreos-extensions") or {}
84+
for extension, extension_data in extensions.items():
85+
if extension == "azure-disk":
86+
vms_with_custom_meta_data.append(
87+
{
88+
"marketplace_name": "azure",
89+
"architecture": arch,
90+
"push_item_class": VHDPushItem,
91+
"release": extension_data.get("release"),
92+
"custom_meta_data": {
93+
"src": extension_data.get("url"),
94+
},
95+
}
96+
)
97+
98+
for vm_item in vms_with_custom_meta_data:
99+
klass = vm_item.get("push_item_class")
100+
rel_klass = klass._RELEASE_TYPE
101+
vm_release = vm_item.get("release") or ""
102+
vm_respin = try_int(vm_release.split("-")[-1]) or 0
103+
release = rel_klass(
104+
arch=vm_item.get("architecture"),
105+
date=release_date,
106+
product=name.upper(),
107+
version=version,
108+
respin=vm_respin,
109+
)
110+
out.append(
111+
klass(
112+
name=name,
113+
description=f"CoreOS image for {name} {version} on {vm_item['marketplace_name']}",
114+
**vm_item["custom_meta_data"],
115+
build_info=KojiBuildInfo(
116+
name=name,
117+
version=version,
118+
release=vm_release,
119+
),
120+
release=release,
121+
marketplace_name=vm_item["marketplace_name"],
122+
)
123+
)
124+
125+
return out
126+
127+
def __iter__(self):
128+
# Get all json files from the paths
129+
json_fs = [self._client.get_json_f(path) for path in self._paths]
130+
131+
# Convert them to lists of push items
132+
push_items_fs = []
133+
for f in futures.as_completed(json_fs, timeout=self._timeout):
134+
push_items_fs.append(
135+
self._executor.submit(self._push_items_from_json, f.result())
136+
)
137+
138+
completed_fs = as_completed_with_timeout_reset(
139+
push_items_fs, timeout=self._timeout
140+
)
141+
for f in completed_fs:
142+
for pushitem in f.result():
143+
yield pushitem
144+
145+
146+
Source.register_backend("coreos", CoreOSSource)

tests/coreos/__init__.py

Whitespace-only changes.

tests/coreos/conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import os
2+
import json
3+
from unittest import mock
4+
5+
import pytest
6+
7+
from pushsource._impl.backend.coreos_source.coreos_client import CoreOSClient
8+
9+
10+
@pytest.fixture
11+
def mock_coreos_client():
12+
with mock.patch(
13+
"pushsource._impl.backend.coreos_source.coreos_client.requests.Session"
14+
):
15+
yield CoreOSClient(url="https://github.com/openshift/installer/tree/master")
16+
17+
18+
@pytest.fixture
19+
def coreos_rhel_9_data():
20+
filepath = os.path.join(os.path.dirname(__file__), "data", "coreos-rhel-9.json")
21+
with open(filepath, "r") as f:
22+
return json.load(f)
23+
24+
25+
@pytest.fixture
26+
def coreos_rhel_10_data():
27+
filepath = os.path.join(os.path.dirname(__file__), "data", "coreos-rhel-10.json")
28+
with open(filepath, "r") as f:
29+
return json.load(f)

0 commit comments

Comments
 (0)