55import aiohttp
66import json5
77from docker .hub import DockerHub
8+ from docker .image_ref import DockerImageRef
89from docker .models .blob import Blob
910from docker .models .manifest import ImageManifest , ManifestFetch , ManifestPlatform
1011from docker .models .repo import RepoInfo
@@ -48,9 +49,23 @@ def __init__(self, metadata: ExtensionMetadata) -> None:
4849 # Versions
4950 self .versions : Dict [str , ExtensionVersion ] = {}
5051
51- # Docker API
52- self .hub : DockerHub = DockerHub (metadata .docker )
53- self .registry : DockerRegistry = DockerRegistry (metadata .docker )
52+ # Parse docker image reference to determine registry
53+ self .image_ref : DockerImageRef = DockerImageRef .parse (metadata .docker )
54+
55+ # Docker Registry V2 API (works for any OCI-compliant registry)
56+ self .registry : DockerRegistry = DockerRegistry (
57+ self .image_ref .repository ,
58+ registry_url = self .image_ref .registry_url ,
59+ auth_url = self .image_ref .auth_url ,
60+ auth_service = self .image_ref .auth_service ,
61+ )
62+
63+ # Docker Hub has a proprietary REST API that provides richer tag
64+ # metadata (ordering, pull counts, per-image details). For other
65+ # registries we fall back to the standard V2 tag list on self.registry.
66+ self .docker_hub : Optional [DockerHub ] = (
67+ DockerHub (self .image_ref .repository ) if self .image_ref .is_dockerhub else None
68+ )
5469
5570 @property
5671 def sorted_versions (self ) -> Dict [str , ExtensionVersion ]:
@@ -101,6 +116,51 @@ def __extract_images_from_tag(tag: Tag) -> List[Image]:
101116 for image in active_images
102117 ]
103118
119+ @staticmethod
120+ def __extract_images_from_manifest (manifest_fetch : ManifestFetch , blob : Blob ) -> List [Image ]:
121+ """
122+ Derive per-platform image information directly from a registry manifest.
123+
124+ This is the registry-agnostic path used when the Docker Hub tag API is
125+ not available (e.g. GHCR, Quay, or any other OCI registry).
126+
127+ Args:
128+ manifest_fetch: The fetched manifest (may be a single image or a manifest list / OCI index).
129+ blob: The blob config for the embedded (ARM) image — used for
130+ platform info when the manifest is a single image.
131+
132+ Returns:
133+ List of Image objects.
134+ """
135+
136+ if isinstance (manifest_fetch .manifest , ImageManifest ):
137+ return [
138+ Image (
139+ digest = manifest_fetch .manifest .config .digest ,
140+ expanded_size = sum (layer .size for layer in manifest_fetch .manifest .layers ),
141+ platform = Platform (
142+ architecture = blob .architecture or "unknown" ,
143+ variant = None ,
144+ os = blob .os or "unknown" ,
145+ ),
146+ )
147+ ]
148+
149+ # ManifestList / OCI Index — entry.size is the manifest document
150+ # size, NOT the image layer size, so we report 0 (unknown) instead.
151+ return [
152+ Image (
153+ digest = entry .digest ,
154+ expanded_size = 0 ,
155+ platform = Platform (
156+ architecture = entry .platform .architecture ,
157+ variant = entry .platform .variant if entry .platform .variant else None ,
158+ os = entry .platform .os if entry .platform .os else None ,
159+ ),
160+ )
161+ for entry in manifest_fetch .manifest .manifests
162+ ]
163+
104164 def __is_compatible (self , platform : ManifestPlatform ) -> bool :
105165 """
106166 Checks if the platform is compatible with embedded devices BlueOS targets.
@@ -150,7 +210,25 @@ async def __extract_valid_embedded_digest(self, fetch: ManifestFetch) -> str:
150210
151211 raise RuntimeError (f"Expected to have a valid image manifest but got a manifest list: { manifest_fetch } " )
152212
153- async def __create_version_from_tag_blob (self , version_tag : Tag , blob : Blob ) -> ExtensionVersion :
213+ async def __create_version (
214+ self ,
215+ tag_name : str ,
216+ blob : Blob ,
217+ manifest : ManifestFetch ,
218+ hub_tag : Optional [Tag ] = None ,
219+ ) -> ExtensionVersion :
220+ """
221+ Build an :class:`ExtensionVersion` from the blob labels, manifest, and
222+ (optionally) Docker Hub tag metadata.
223+
224+ Args:
225+ tag_name: The semver tag string (e.g. ``"1.2.3"``).
226+ blob: The config blob for the embedded ARM image.
227+ manifest: The top-level manifest fetch for this tag.
228+ hub_tag: If available, the Docker Hub ``Tag`` object that provides
229+ rich per-image metadata (sizes, architecture, status).
230+ """
231+
154232 labels = blob .config .Labels
155233
156234 authors = labels .get ("authors" , "[]" )
@@ -163,7 +241,7 @@ async def __create_version_from_tag_blob(self, version_tag: Tag, blob: Blob) ->
163241
164242 readme = labels .get ("readme" , None )
165243 if readme is not None :
166- url = readme .replace (r"{tag}" , version_tag . name )
244+ url = readme .replace (r"{tag}" , tag_name )
167245 try :
168246 readme = await Extension .fetch_readme (url )
169247 try :
@@ -175,17 +253,23 @@ async def __create_version_from_tag_blob(self, version_tag: Tag, blob: Blob) ->
175253 Logger .warning (self .identifier , str (error ))
176254 readme = "No README available"
177255
178- images = self .__extract_images_from_tag (version_tag )
256+ # Prefer Docker Hub's rich per-image data when available; otherwise
257+ # derive the image list from the manifest (works for any registry).
258+ images : List [Image ] = []
259+ if hub_tag :
260+ images = self .__extract_images_from_tag (hub_tag )
261+ if not images :
262+ images = self .__extract_images_from_manifest (manifest , blob )
179263 if not images :
180264 Logger .error (
181265 self .identifier ,
182- f"Could not find images associated with tag { version_tag . name } for extension { self .identifier } " ,
266+ f"Could not find images associated with tag { tag_name } for extension { self .identifier } " ,
183267 )
184268
185- tag_identifier = str (uuid .uuid5 (uuid .NAMESPACE_DNS , f"{ self .identifier } .{ version_tag . name } " ))
269+ tag_identifier = str (uuid .uuid5 (uuid .NAMESPACE_DNS , f"{ self .identifier } .{ tag_name } " ))
186270 return ExtensionVersion (
187271 identifier = tag_identifier ,
188- tag = version_tag . name ,
272+ tag = tag_name ,
189273 type = ExtensionType (labels .get ("type" , ExtensionType .OTHER .value )),
190274 website = links .pop ("website" , labels .get ("website" , None )),
191275 readme = readme ,
@@ -197,19 +281,19 @@ async def __create_version_from_tag_blob(self, version_tag: Tag, blob: Blob) ->
197281 docs = json5 .loads (docs_raw ) if docs_raw else None ,
198282 company = json5 .loads (company_raw ) if company_raw else None ,
199283 permissions = json5 .loads (permissions_raw ) if permissions_raw else None ,
200- images = self . __extract_images_from_tag ( version_tag ) ,
284+ images = images ,
201285 )
202286
203- async def __process_tag_version (self , tag : Tag ) -> None :
287+ async def __process_tag (self , tag_name : str , hub_tag : Optional [ Tag ] = None ) -> None :
204288 """
205- Process a tag and create a version object for it and store it in the versions
206- dictionary property .
289+ Fetch the manifest and blob for *tag_name*, build an
290+ :class:`ExtensionVersion`, and store it in ``self.versions`` .
207291
208292 Args:
209- tag (Tag): Tag to process.
293+ tag_name: The tag string to process.
294+ hub_tag: Optional Docker Hub ``Tag`` object for richer metadata.
210295 """
211296
212- tag_name = tag .name
213297 try :
214298 if not valid_semver (tag_name ):
215299 raise ValueError (f"Invalid version naming: { tag_name } " )
@@ -220,7 +304,7 @@ async def __process_tag_version(self, tag: Tag) -> None:
220304 embedded_digest = await self .__extract_valid_embedded_digest (manifest )
221305 blob = await self .registry .get_manifest_blob (embedded_digest )
222306
223- self .versions [tag_name ] = await self .__create_version_from_tag_blob ( tag , blob )
307+ self .versions [tag_name ] = await self .__create_version ( tag_name , blob , manifest , hub_tag )
224308
225309 Logger .info (self .identifier , f"Generated version entry { tag_name } for extension { self .identifier } " )
226310 except ValueError as error :
@@ -251,15 +335,20 @@ async def inflate(self, tag: Optional[Tag] = None) -> None:
251335 """
252336
253337 if tag :
254- return await self .__process_tag_version ( tag )
338+ return await self .__process_tag ( tag . name , hub_tag = tag )
255339
256340 try :
257- tags = await self .hub .get_tags ()
258- self .repo_info = await self .hub .repo_info ()
341+ if self .docker_hub :
342+ # Docker Hub: use its proprietary API for ordered results,
343+ # rich per-image metadata, and download stats.
344+ hub_tags = await self .docker_hub .get_tags ()
345+ self .repo_info = await self .docker_hub .repo_info ()
346+
347+ await asyncio .gather (* (self .__process_tag (t .name , hub_tag = t ) for t in hub_tags .results ))
348+ else :
349+ # Any other OCI registry: use the standard V2 tag list.
350+ tag_names = await self .registry .list_tags ()
351+
352+ await asyncio .gather (* (self .__process_tag (name ) for name in tag_names ))
259353 except Exception as error : # pylint: disable=broad-except
260354 Logger .error (self .identifier , f"Unable to fetch tags for { self .identifier } , error: { error } " )
261- return
262-
263- # We may want to split and process first 5 tags prior to make sure we dont reach limit and always have the
264- # latest ones processed.
265- await asyncio .gather (* (self .__process_tag_version (tag ) for tag in tags .results ))
0 commit comments