Skip to content

Commit 2eb5e43

Browse files
committed
install elements by start spec
1 parent b6028a0 commit 2eb5e43

9 files changed

Lines changed: 386 additions & 14 deletions

File tree

docs/openapi/openapi_user.yaml

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22395,10 +22395,49 @@ paths:
2239522395
application/json:
2239622396
schema:
2239722397
$ref: '#/components/schemas/Manifest_Create'
22398+
/v1/em/manifests/download/:
22399+
post:
22400+
summary: Download manifest
22401+
tags:
22402+
- Manifest
22403+
parameters: []
22404+
responses:
22405+
'200':
22406+
description: Manifest_Get
22407+
content:
22408+
application/json:
22409+
schema:
22410+
$ref: '#/components/schemas/Manifest_Get'
22411+
default:
22412+
$ref: '#/components/responses/Error'
22413+
operationId: Create_v1_em_manifests_download
22414+
requestBody:
22415+
description: Trigger a password reset email for the specified user
22416+
required: true
22417+
content:
22418+
application/json:
22419+
schema:
22420+
type: object
22421+
required:
22422+
- name
22423+
properties:
22424+
name:
22425+
type: string
22426+
description: Name of the manifest
22427+
example: empty
22428+
version:
22429+
type: string
22430+
description: Version of the manifest
22431+
example: 0.1.2
22432+
repository:
22433+
type: string
22434+
description: Url of the repository
22435+
example: https://repo.exordos.com/exordos-elements/
2239822436
/v1/em/manifests/schema/:
2239922437
get:
2240022438
summary: Get manifest schema
22401-
tags: []
22439+
tags:
22440+
- Manifest
2240222441
parameters: []
2240322442
responses:
2240422443
'200':

exordos_core/clients/__init__.py

Whitespace-only changes.

exordos_core/clients/repo.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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)

exordos_core/cmd/bootstrap.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
SPEC_PATH = "/mnt/cdrom/spec.json"
4848
MANIFEST_PATH = "/mnt/cdrom/core.yaml"
4949
MANIFEST_COLLECTION = "/v1/em/manifests/"
50+
DOWNLOAD_MANIFEST_URL = f"{MANIFEST_COLLECTION}download/"
5051
ECOSYSTEM_REALM_MANIFEST_PATH = "/mnt/cdrom/ecosystem_realm.yaml"
5152
MAIN_SUBNET_UUID = sys_uuid.UUID("c910a7e1-61ae-4d56-bdd6-a59faa3cbda3")
5253

@@ -265,7 +266,8 @@ def _ensure_exordos_config(spec: dict[str, tp.Any]):
265266

266267
def _install_element_manifest(
267268
element_name: str,
268-
manifest_path: str,
269+
element_version: str | None,
270+
manifest_path: str | None,
269271
spec: dict[str, tp.Any],
270272
):
271273
"""Idempotent element manifest installation."""
@@ -277,13 +279,6 @@ def _install_element_manifest(
277279
LOG.info("Element %s already installed, skipping", element_name)
278280
return
279281

280-
if not os.path.exists(manifest_path):
281-
LOG.info("No manifest file found at %s", manifest_path)
282-
return
283-
284-
with open(manifest_path) as f:
285-
manifest_data = yaml.safe_load(f)
286-
287282
auth = http_base.CoreIamAuthenticator(
288283
base_url="http://localhost:11010",
289284
username=CONF.core_user,
@@ -297,7 +292,22 @@ def _install_element_manifest(
297292
base_url="http://localhost:11010", auth=auth
298293
)
299294

300-
manifest_data = client.create(MANIFEST_COLLECTION, manifest_data)
295+
if manifest_path is not None and os.path.exists(manifest_path):
296+
with open(manifest_path) as f:
297+
manifest_data = yaml.safe_load(f)
298+
manifest_data = client.create(MANIFEST_COLLECTION, manifest_data)
299+
elif element_version is not None:
300+
manifest_data = client.create(
301+
DOWNLOAD_MANIFEST_URL,
302+
{"name": element_name, "version": element_version},
303+
)
304+
else:
305+
raise ValueError(
306+
"No manifest path or version provided for element %s", element_name
307+
)
308+
309+
LOG.info("Installing manifest %s")
310+
301311
try:
302312
client.do_action(
303313
MANIFEST_COLLECTION, "install", manifest_data["uuid"], invoke=True
@@ -388,10 +398,20 @@ def main() -> None:
388398
)
389399
bootstrap_defaults.add_core_set(spec)
390400
_ensure_exordos_config(spec)
391-
_install_element_manifest("core", CONF.manifest_path, spec)
401+
_install_element_manifest("core", None, CONF.manifest_path, spec)
392402
_install_element_manifest(
393-
"ecosystem_realm", CONF.ecosystem_realm_manifest_path, spec
403+
"ecosystem_realm", None, CONF.ecosystem_realm_manifest_path, spec
394404
)
405+
if elements := spec.get("elements", []):
406+
if isinstance(elements, list):
407+
for element_name in elements:
408+
_install_element_manifest(element_name, "latest", None, spec)
409+
elif isinstance(elements, dict):
410+
for element_name, element_version in elements.items():
411+
_install_element_manifest(
412+
element_name, element_version, None, spec
413+
)
414+
395415
_set_defaults_vs(spec)
396416
return
397417
except Exception:

exordos_core/common/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,5 @@
9494

9595
REPOSITORY_URL = "https://repo.exordos.com"
9696
ELEMENTS_PATH = "exordos-elements"
97+
ELEMENT_REPO_URL = f"{REPOSITORY_URL}/{ELEMENTS_PATH}"
9798
INVENTORY_URL = f"{REPOSITORY_URL}/{ELEMENTS_PATH}/inventory.json"

exordos_core/common/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,9 @@ class NamespaceNotFound(GCException):
5252
@property
5353
def code(self) -> int:
5454
return 1000
55+
56+
57+
class ManifestNotFound(GCException):
58+
__template__ = "validate error: {err}"
59+
60+
err: str

exordos_core/tests/functional/restapi/em/test_em.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,34 @@ def test_schema(
5050
assert response.status_code == 200
5151
assert isinstance(response.json(), dict)
5252

53+
def test_download(
54+
self,
55+
user_api_client: iam_clients.GenesisCoreTestRESTClient,
56+
auth_user_admin: iam_clients.GenesisCoreAuth,
57+
):
58+
client = user_api_client(auth_user_admin)
59+
download_url = client.build_collection_uri(["em", "manifests", "download"])
60+
61+
element_name = "empty"
62+
63+
response = client.post(download_url, json={"name": element_name})
64+
output = response.json()
65+
assert response.status_code == 201
66+
assert isinstance(output, dict)
67+
assert output["name"] == element_name
68+
69+
delete_url = client.build_resource_uri(["em", "manifests", output["uuid"]])
70+
response = client.delete(delete_url)
71+
assert response.status_code == 204
72+
73+
response = client.post(
74+
download_url, json={"name": element_name, "version": "latest"}
75+
)
76+
output = response.json()
77+
assert response.status_code == 201
78+
assert isinstance(output, dict)
79+
assert output["name"] == element_name
80+
5381
def test_manifests(
5482
self,
5583
user_api_client: iam_clients.GenesisCoreTestRESTClient,

0 commit comments

Comments
 (0)