|
| 1 | +# Copyright 2025 Genesis Corporation. |
| 2 | +# |
| 3 | +# All Rights Reserved. |
| 4 | +# |
| 5 | +# Licensed under the Apache License, Version 2.0 (the "License"); you may |
| 6 | +# not use this file except in compliance with the License. You may obtain |
| 7 | +# a copy of the License at |
| 8 | +# |
| 9 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# Unless required by applicable law or agreed to in writing, software |
| 12 | +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| 13 | +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 14 | +# License for the specific language governing permissions and limitations |
| 15 | +# under the License. |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import json |
| 20 | +from pathlib import Path |
| 21 | +import re |
| 22 | +import typing as tp |
| 23 | +import urllib.parse |
| 24 | +import urllib.request |
| 25 | + |
| 26 | +import yaml |
| 27 | + |
| 28 | +from exordos_core.common import constants as c |
| 29 | +from exordos_core.common.exceptions import ManifestNotFound |
| 30 | + |
| 31 | + |
| 32 | +def _join_url(*parts: str) -> str: |
| 33 | + # Join URL parts ensuring single slashes |
| 34 | + base = parts[0] |
| 35 | + for p in parts[1:]: |
| 36 | + base = urllib.parse.urljoin(base.rstrip("/") + "/", p) |
| 37 | + return base |
| 38 | + |
| 39 | + |
| 40 | +def _http_get(url: str) -> bytes: |
| 41 | + req = urllib.request.Request( |
| 42 | + url, headers={"User-Agent": f"{c.GLOBAL_SERVICE_NAME}/1.0"} |
| 43 | + ) |
| 44 | + with urllib.request.urlopen(req, timeout=10) as resp: |
| 45 | + return resp.read() |
| 46 | + |
| 47 | + |
| 48 | +def _extract_hrefs(html: str) -> list[str]: |
| 49 | + # Extract href values from simple directory listings |
| 50 | + return re.findall(r'href=["\']([^"\']+)["\']', html, flags=re.IGNORECASE) |
| 51 | + |
| 52 | + |
| 53 | +class Repository: |
| 54 | + def __init__(self, repository_url: str): |
| 55 | + self.repository_url = repository_url |
| 56 | + |
| 57 | + def element_url( |
| 58 | + self, |
| 59 | + manifest_name: str, |
| 60 | + ) -> str: |
| 61 | + return _join_url(self.repository_url, manifest_name) |
| 62 | + |
| 63 | + @classmethod |
| 64 | + def element_html(cls, element_url: str) -> str: |
| 65 | + try: |
| 66 | + element_html = _http_get(element_url).decode("utf-8", errors="ignore") |
| 67 | + return element_html |
| 68 | + except Exception as exc: |
| 69 | + raise ManifestNotFound(err=f"Element not found at {element_url}: {exc}") |
| 70 | + |
| 71 | + @classmethod |
| 72 | + def get_inventory_url( |
| 73 | + cls, |
| 74 | + element_url: str, |
| 75 | + version: str, |
| 76 | + ) -> str: |
| 77 | + return _join_url(element_url, version, "inventory.json") |
| 78 | + |
| 79 | + def check_repo(self) -> None: |
| 80 | + try: |
| 81 | + _http_get(self.repository_url).decode("utf-8", errors="ignore") |
| 82 | + except Exception as exc: |
| 83 | + raise ManifestNotFound( |
| 84 | + err=f"Failed to access repository: {self.repository_url}: {exc}" |
| 85 | + ) |
| 86 | + |
| 87 | + def get_all_elements(self) -> list[str]: |
| 88 | + inventory_url = _join_url(self.repository_url, "inventory.json") |
| 89 | + try: |
| 90 | + result = _http_get(inventory_url) |
| 91 | + except urllib.request.HTTPError as exc: |
| 92 | + if exc.code == 404: |
| 93 | + raise ManifestNotFound( |
| 94 | + err=f"Failed to access repository: {inventory_url}: {exc}" |
| 95 | + ) |
| 96 | + raise |
| 97 | + inventory = json.loads(result) |
| 98 | + return sorted(inventory["elements"].keys()) |
| 99 | + |
| 100 | + def get_element_versions(self, element_name: str) -> list[str]: |
| 101 | + try: |
| 102 | + # 1) List repository root to ensure element exists |
| 103 | + # (optional but validates repo) |
| 104 | + _http_get(self.repository_url).decode("utf-8", errors="ignore") |
| 105 | + except Exception as exc: |
| 106 | + raise ManifestNotFound( |
| 107 | + err=f"Failed to access repository: {self.repository_url}: {exc}" |
| 108 | + ) |
| 109 | + |
| 110 | + # 2) List element directory to get versions |
| 111 | + element_url = _join_url(self.repository_url, element_name) |
| 112 | + try: |
| 113 | + element_html = _http_get(element_url).decode("utf-8", errors="ignore") |
| 114 | + except Exception as exc: |
| 115 | + raise ManifestNotFound( |
| 116 | + err=f"Element '{element_name}' not found at {element_url}: {exc}" |
| 117 | + ) |
| 118 | + |
| 119 | + version_dirs = [h for h in _extract_hrefs(element_html)] |
| 120 | + if not version_dirs: |
| 121 | + raise ManifestNotFound( |
| 122 | + err=f"No version directories found for element '{element_name}' " |
| 123 | + f"at {element_url}" |
| 124 | + ) |
| 125 | + # Remove last slash from all versions |
| 126 | + version_dirs = [v.rstrip("/") for v in version_dirs] |
| 127 | + # Remove latest version from list if exists |
| 128 | + if "latest" in version_dirs: |
| 129 | + version_dirs.remove("latest") |
| 130 | + return version_dirs |
| 131 | + |
| 132 | + def get_manifest( |
| 133 | + self, element_name: str, element_version: str | None = None |
| 134 | + ) -> dict: |
| 135 | + self.check_repo() |
| 136 | + |
| 137 | + element_url = self.element_url(element_name) |
| 138 | + |
| 139 | + if not element_version: |
| 140 | + latest_dir = "latest" |
| 141 | + else: |
| 142 | + latest_dir = element_version |
| 143 | + |
| 144 | + inventory_url = self.get_inventory_url(element_url, latest_dir) |
| 145 | + inventory = self._element_inventory(inventory_url) |
| 146 | + |
| 147 | + target_manifest_path = self.get_manifest_path_from_inventory( |
| 148 | + inventory, element_name, inventory_url |
| 149 | + ) |
| 150 | + |
| 151 | + manifest_url = self._manifest_url(element_url, latest_dir, target_manifest_path) |
| 152 | + manifest = self.get_manifest_by_url(manifest_url) |
| 153 | + return manifest |
| 154 | + |
| 155 | + @classmethod |
| 156 | + def get_manifest_path_from_inventory( |
| 157 | + cls, inventory: dict[str, tp.Any], manifest_name: str, inventory_url: str |
| 158 | + ) -> str: |
| 159 | + target_manifest_path = None |
| 160 | + for manifest_path in inventory["manifests"]: |
| 161 | + stem = Path(manifest_path).stem |
| 162 | + if stem == manifest_name: |
| 163 | + target_manifest_path = manifest_path |
| 164 | + if target_manifest_path is None: |
| 165 | + raise ManifestNotFound( |
| 166 | + err=f"Manifest '{manifest_name}' not found in inventory at {inventory_url}" |
| 167 | + ) |
| 168 | + return target_manifest_path |
| 169 | + |
| 170 | + @classmethod |
| 171 | + def get_manifest_by_url(cls, manifest_url: str) -> dict[str, tp.Any]: |
| 172 | + try: |
| 173 | + data = _http_get(manifest_url) |
| 174 | + manifest = yaml.safe_load(data) |
| 175 | + if not isinstance(manifest, dict): |
| 176 | + raise ManifestNotFound( |
| 177 | + err=f"Manifest at {manifest_url} is not a YAML mapping" |
| 178 | + ) |
| 179 | + return manifest |
| 180 | + except ManifestNotFound: |
| 181 | + raise |
| 182 | + except Exception as exc: |
| 183 | + raise ManifestNotFound( |
| 184 | + err=f"Failed to download or parse manifest at {manifest_url}: {exc}" |
| 185 | + ) |
| 186 | + |
| 187 | + @classmethod |
| 188 | + def _element_inventory(cls, inventory_url: str) -> dict[str, tp.Any]: |
| 189 | + try: |
| 190 | + inventory = json.loads(_http_get(inventory_url)) |
| 191 | + return inventory |
| 192 | + except Exception as exc: |
| 193 | + raise ManifestNotFound( |
| 194 | + err=f"Failed to download or parse inventory at {inventory_url}: {exc}" |
| 195 | + ) |
| 196 | + |
| 197 | + @classmethod |
| 198 | + def _manifest_url( |
| 199 | + cls, |
| 200 | + element_url: str, |
| 201 | + version: str, |
| 202 | + manifest_name: str, |
| 203 | + ) -> str: |
| 204 | + return _join_url(element_url, version, "manifests/", manifest_name) |
0 commit comments