Skip to content

Commit 3ab4255

Browse files
committed
install elements by start spec
1 parent b6028a0 commit 3ab4255

9 files changed

Lines changed: 360 additions & 14 deletions

File tree

docs/openapi/openapi_user.yaml

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22395,10 +22395,50 @@ 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: Download the manifest for the specified element name and
22416+
version
22417+
required: true
22418+
content:
22419+
application/json:
22420+
schema:
22421+
type: object
22422+
required:
22423+
- name
22424+
properties:
22425+
name:
22426+
type: string
22427+
description: Name of the manifest
22428+
example: empty
22429+
version:
22430+
type: string
22431+
description: Version of the manifest
22432+
example: 0.1.2
22433+
repository:
22434+
type: string
22435+
description: Url of the repository
22436+
example: https://repo.exordos.com/exordos-elements/
2239822437
/v1/em/manifests/schema/:
2239922438
get:
2240022439
summary: Get manifest schema
22401-
tags: []
22440+
tags:
22441+
- Manifest
2240222442
parameters: []
2240322443
responses:
2240422444
'200':

exordos_core/clients/__init__.py

Whitespace-only changes.

exordos_core/clients/repo.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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_manifest(
88+
self, element_name: str, element_version: str | None = None
89+
) -> dict:
90+
self.check_repo()
91+
92+
element_url = self.element_url(element_name)
93+
94+
if not element_version:
95+
latest_dir = "latest"
96+
else:
97+
latest_dir = element_version
98+
99+
inventory_url = self.get_inventory_url(element_url, latest_dir)
100+
inventory = self._element_inventory(inventory_url)
101+
102+
target_manifest_path = self.get_manifest_path_from_inventory(
103+
inventory, element_name, inventory_url
104+
)
105+
106+
manifest_url = self._manifest_url(element_url, latest_dir, target_manifest_path)
107+
manifest = self.get_manifest_by_url(manifest_url)
108+
return manifest
109+
110+
@classmethod
111+
def get_manifest_path_from_inventory(
112+
cls, inventory: dict[str, tp.Any], manifest_name: str, inventory_url: str
113+
) -> str:
114+
target_manifest_path = None
115+
for manifest_path in inventory["manifests"]:
116+
stem = Path(manifest_path).stem
117+
if stem == manifest_name:
118+
target_manifest_path = manifest_path
119+
if target_manifest_path is None:
120+
raise ManifestNotFound(
121+
err=f"Manifest '{manifest_name}' not found in inventory at {inventory_url}"
122+
)
123+
return target_manifest_path
124+
125+
@classmethod
126+
def get_manifest_by_url(cls, manifest_url: str) -> dict[str, tp.Any]:
127+
try:
128+
data = _http_get(manifest_url)
129+
manifest = yaml.safe_load(data)
130+
if not isinstance(manifest, dict):
131+
raise ManifestNotFound(
132+
err=f"Manifest at {manifest_url} is not a YAML mapping"
133+
)
134+
return manifest
135+
except ManifestNotFound:
136+
raise
137+
except Exception as exc:
138+
raise ManifestNotFound(
139+
err=f"Failed to download or parse manifest at {manifest_url}: {exc}"
140+
)
141+
142+
@classmethod
143+
def _element_inventory(cls, inventory_url: str) -> dict[str, tp.Any]:
144+
try:
145+
inventory = json.loads(_http_get(inventory_url))
146+
return inventory
147+
except Exception as exc:
148+
raise ManifestNotFound(
149+
err=f"Failed to download or parse inventory at {inventory_url}: {exc}"
150+
)
151+
152+
@classmethod
153+
def _manifest_url(
154+
cls,
155+
element_url: str,
156+
version: str,
157+
manifest_name: str,
158+
) -> str:
159+
return _join_url(element_url, version, "manifests/", manifest_name)

exordos_core/cmd/bootstrap.py

Lines changed: 43 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,34 @@ 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:
296+
if os.path.exists(manifest_path):
297+
with open(manifest_path) as f:
298+
manifest_data = yaml.safe_load(f)
299+
manifest_data = client.create(MANIFEST_COLLECTION, manifest_data)
300+
elif element_version is not None:
301+
manifest_data = client.create(
302+
DOWNLOAD_MANIFEST_URL,
303+
{"name": element_name, "version": element_version},
304+
)
305+
else:
306+
LOG.info(
307+
"No manifest file found at %s and no version provided, skipping",
308+
manifest_path,
309+
)
310+
return
311+
elif element_version is not None:
312+
manifest_data = client.create(
313+
DOWNLOAD_MANIFEST_URL,
314+
{"name": element_name, "version": element_version},
315+
)
316+
else:
317+
raise ValueError(
318+
f"No manifest path or version provided for element {element_name}"
319+
)
320+
321+
LOG.info("Installing manifest %s", element_name)
322+
301323
try:
302324
client.do_action(
303325
MANIFEST_COLLECTION, "install", manifest_data["uuid"], invoke=True
@@ -388,10 +410,20 @@ def main() -> None:
388410
)
389411
bootstrap_defaults.add_core_set(spec)
390412
_ensure_exordos_config(spec)
391-
_install_element_manifest("core", CONF.manifest_path, spec)
413+
_install_element_manifest("core", None, CONF.manifest_path, spec)
392414
_install_element_manifest(
393-
"ecosystem_realm", CONF.ecosystem_realm_manifest_path, spec
415+
"ecosystem_realm", None, CONF.ecosystem_realm_manifest_path, spec
394416
)
417+
if elements := spec.get("elements", []):
418+
if isinstance(elements, list):
419+
for element_name in elements:
420+
_install_element_manifest(element_name, "latest", None, spec)
421+
elif isinstance(elements, dict):
422+
for element_name, element_version in elements.items():
423+
_install_element_manifest(
424+
element_name, element_version, None, spec
425+
)
426+
395427
_set_defaults_vs(spec)
396428
return
397429
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)