aipmodel 0.2.68__tar.gz → 0.2.69__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aipmodel-0.2.68 → aipmodel-0.2.69}/PKG-INFO +1 -1
- {aipmodel-0.2.68 → aipmodel-0.2.69}/README.md +22 -20
- {aipmodel-0.2.68 → aipmodel-0.2.69}/aipmodel/CephS3Manager.py +3 -1
- aipmodel-0.2.69/aipmodel/__init__.py +15 -0
- aipmodel-0.2.69/aipmodel/exceptions.py +23 -0
- {aipmodel-0.2.68 → aipmodel-0.2.69}/aipmodel/model_registry.py +349 -58
- {aipmodel-0.2.68 → aipmodel-0.2.69}/aipmodel/template.py +1 -1
- {aipmodel-0.2.68 → aipmodel-0.2.69}/aipmodel.egg-info/PKG-INFO +1 -1
- {aipmodel-0.2.68 → aipmodel-0.2.69}/aipmodel.egg-info/SOURCES.txt +1 -0
- {aipmodel-0.2.68 → aipmodel-0.2.69}/setup.py +1 -1
- aipmodel-0.2.68/aipmodel/__init__.py +0 -10
- {aipmodel-0.2.68 → aipmodel-0.2.69}/MANIFEST.in +0 -0
- {aipmodel-0.2.68 → aipmodel-0.2.69}/aipmodel/acl_manager.py +0 -0
- {aipmodel-0.2.68 → aipmodel-0.2.69}/aipmodel/update_checker.py +0 -0
- {aipmodel-0.2.68 → aipmodel-0.2.69}/aipmodel.egg-info/dependency_links.txt +0 -0
- {aipmodel-0.2.68 → aipmodel-0.2.69}/aipmodel.egg-info/requires.txt +0 -0
- {aipmodel-0.2.68 → aipmodel-0.2.69}/aipmodel.egg-info/top_level.txt +0 -0
- {aipmodel-0.2.68 → aipmodel-0.2.69}/requirements.txt +0 -0
- {aipmodel-0.2.68 → aipmodel-0.2.69}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AIP Model SDK
|
|
2
2
|
|
|
3
|
-
This SDK provides a robust interface for registering, uploading, downloading, listing, and deleting machine learning models using
|
|
3
|
+
This SDK provides a robust interface for registering, uploading, downloading, listing, and deleting machine learning models using Mlops-Core and S3 (Ceph) as storage. It supports **automatic versioning**, **categorization**, **tagging**, **metadata transparency**, **web link generation**, and fail-fast connectivity checks.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -20,11 +20,13 @@ pip install aipmodel
|
|
|
20
20
|
To use the SDK, you must set up your environment variables. You can create a `.env` file in your project root:
|
|
21
21
|
|
|
22
22
|
```env
|
|
23
|
-
#
|
|
24
|
-
CLEARML_API_HOST
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
# Mlops-Core API Credentials
|
|
24
|
+
# (The legacy CLEARML_API_HOST / CLEARML_WEB_HOST env var names and constructor
|
|
25
|
+
# kwargs are still accepted as a deprecated fallback for backward compatibility.)
|
|
26
|
+
MLOPS_CORE_API_HOST="http://your-mlops-core-server:8008"
|
|
27
|
+
MLOPS_CORE_WEB_HOST="http://your-mlops-core-web-ui:8080" # Required for generating model web links
|
|
28
|
+
MLOPS_CORE_ACCESS_KEY="your_access_key"
|
|
29
|
+
MLOPS_CORE_SECRET_KEY="your_secret_key"
|
|
28
30
|
|
|
29
31
|
# Ceph / S3 Storage
|
|
30
32
|
CEPH_ENDPOINT_URL="https://your-ceph-endpoint.com"
|
|
@@ -59,7 +61,7 @@ S3_BOTO_MAX_ATTEMPTS="10" # botocore request-level retry attempts (adapti
|
|
|
59
61
|
|
|
60
62
|
```
|
|
61
63
|
|
|
62
|
-
> **Note:** `
|
|
64
|
+
> **Note:** `MLOPS_CORE_WEB_HOST` is new. If not provided, the SDK will default to the API host, which might result in incorrect clickable links in the output. The legacy `CLEARML_API_HOST` / `CLEARML_WEB_HOST` names still work but are deprecated; when both old and new names are set, the `MLOPS_CORE_*` name wins.
|
|
63
65
|
|
|
64
66
|
> Every constant the SDK relies on (timeouts, categories, transfer tuning, ACL) is overridable via `.env` — nothing important is hardcoded.
|
|
65
67
|
|
|
@@ -97,7 +99,7 @@ reset). The SDK now **retries these automatically**, so `add_model`,
|
|
|
97
99
|
|
|
98
100
|
`add_model` is **concurrency-safe** — you can run many adds at the same time:
|
|
99
101
|
|
|
100
|
-
- Adds with **different model names** run **fully in parallel** (own
|
|
102
|
+
- Adds with **different model names** run **fully in parallel** (own Mlops-Core id,
|
|
101
103
|
own S3 prefix — no interference).
|
|
102
104
|
- Adds with the **same `(project, model_name)`** are **serialized** by a
|
|
103
105
|
process-wide lock. Otherwise two simultaneous same-name adds would race on the
|
|
@@ -116,7 +118,7 @@ reset). The SDK now **retries these automatically**, so `add_model`,
|
|
|
116
118
|
automatically on the next add to that model.
|
|
117
119
|
- The lock is **per-process**. One server process (e.g. a single `uvicorn`
|
|
118
120
|
worker) is fully protected; for multiple workers/replicas add a distributed
|
|
119
|
-
lock (
|
|
121
|
+
lock (Mlops-Core/Redis).
|
|
120
122
|
|
|
121
123
|
## Access Control (ACL)
|
|
122
124
|
|
|
@@ -143,7 +145,7 @@ Requires `ACL_ENABLED=true` and that you have `admin`-level access on the model.
|
|
|
143
145
|
|
|
144
146
|
## Cross-User Visibility (Public Models)
|
|
145
147
|
|
|
146
|
-
Every model lives in its owner's own
|
|
148
|
+
Every model lives in its owner's own Mlops-Core project (`project_<owner_username>`) — that part hasn't changed. What changed is **how `list_models`/`get_model_id_by_name`/`get_model_info`/`get_model` look things up**:
|
|
147
149
|
|
|
148
150
|
- **`list_models()`** (default `include_public=True`): returns your own models (private + public) **plus** every other user's model that is marked `public=True`. Other users' **private** models are never returned, under any setting. Each entry's `owner` field is resolved from the model's actual project, so models from other users show their real owner, not yours.
|
|
149
151
|
- Pass `include_public=False` to get the exact pre-existing behavior: your own project only, nothing from other users.
|
|
@@ -152,7 +154,7 @@ Every model lives in its owner's own ClearML project (`project_<owner_username>`
|
|
|
152
154
|
- **`get_model_link`** now resolves the link using the model's *actual* owning project (previously it always used your own project ID, which silently produced a wrong link for any model you didn't own — this never came up before because nothing could return another user's model anyway).
|
|
153
155
|
- Write operations (`add_model`, `delete_model`, `set_model_public`, `set_latest_version`) are **unchanged** — they still only ever operate within your own project by name. To act on a model you don't own (e.g. as an ACL-granted admin), pass its `model_id` directly (already supported) rather than its name.
|
|
154
156
|
|
|
155
|
-
**Performance note:** when `include_public=True` (the default), `list_models()` makes one extra unscoped
|
|
157
|
+
**Performance note:** when `include_public=True` (the default), `list_models()` makes one extra unscoped Mlops-Core query (`models.get_all` with no project filter) to find other users' public models — on a very large Mlops-Core deployment this returns every model company-wide before filtering down to public ones. If that ever becomes slow, set `include_public=False` and only fetch other users' models on demand via `owner=`.
|
|
156
158
|
|
|
157
159
|
**Backward compatibility:** `get_model_id_by_name`, `get_model`, `get_model_info` keep their exact old signatures plus one new optional trailing argument — no existing call site breaks. `list_models()`'s default *output* now includes more rows than before (other users' public models) since that was the explicit point of this change; pass `include_public=False` if your code depends on the exact old row set.
|
|
158
160
|
|
|
@@ -174,8 +176,8 @@ print("\n[STEP 1] Initialize MLOps Manager")
|
|
|
174
176
|
# verbose=True enables detailed logging and transparency mode.
|
|
175
177
|
manager = MLOpsManager(
|
|
176
178
|
user_token=os.getenv("USER_TOKEN"),
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
MLOPS_CORE_API_HOST=os.getenv("MLOPS_CORE_API_HOST"),
|
|
180
|
+
MLOPS_CORE_WEB_HOST=os.getenv("MLOPS_CORE_WEB_HOST"), # For generating UI links
|
|
179
181
|
CEPH_ENDPOINT_URL=os.getenv("CEPH_ENDPOINT_URL"),
|
|
180
182
|
USER_MANAGEMENT_API=os.getenv("USER_MANAGEMENT_API"),
|
|
181
183
|
verbose=True
|
|
@@ -234,7 +236,7 @@ print(f" -> Registered ID: {hf_model_id}")
|
|
|
234
236
|
|
|
235
237
|
# --- STEP 6: Get Detailed Info ---
|
|
236
238
|
print("\n[STEP 6] Get Detailed Model Info")
|
|
237
|
-
# Displays ID, Public status, Total Versions, Size, and
|
|
239
|
+
# Displays ID, Public status, Total Versions, Size, and Mlops-Core Web Link
|
|
238
240
|
manager.get_model_info("demo_model_local")
|
|
239
241
|
|
|
240
242
|
# --- STEP 7: List All Models ---
|
|
@@ -287,7 +289,7 @@ print("\n[STEP 10] Deletion")
|
|
|
287
289
|
# Delete a specific version (Removes files + Metadata entry)
|
|
288
290
|
manager.delete_model(model_name="demo_model_local", version="v1.0")
|
|
289
291
|
|
|
290
|
-
# Delete the entire model (Removes all versions +
|
|
292
|
+
# Delete the entire model (Removes all versions + Mlops-Core ID)
|
|
291
293
|
manager.delete_model(model_name="demo_model_hf")
|
|
292
294
|
|
|
293
295
|
```
|
|
@@ -300,14 +302,14 @@ manager.delete_model(model_name="demo_model_hf")
|
|
|
300
302
|
|
|
301
303
|
| Function | Input Arguments | Description |
|
|
302
304
|
| --- | --- | --- |
|
|
303
|
-
| **`__init__`** | `user_token`, `
|
|
305
|
+
| **`__init__`** | `user_token`, `MLOPS_CORE_API_HOST`, `MLOPS_CORE_WEB_HOST`, `CEPH_ENDPOINT_URL`, `USER_MANAGEMENT_API`, `verbose`, `ACL_API_HOST`, `ACL_ENABLED` | Initializes connections. Performs fail-fast health checks. `ACL_*` args (or env vars) toggle ACL enforcement (default: disabled). The legacy `CLEARML_API_HOST` / `CLEARML_WEB_HOST` kwargs are still accepted as deprecated aliases (the `MLOPS_CORE_*` kwargs win if both are given). |
|
|
304
306
|
| **`add_model`** | `source_type`, `model_name`, `version`, `source_path`, `code_path`, `category`, `tags`, `external_ceph_*`, `model_folder_name` | Uploads a model. `version`: if `None`, generates `_v{n}` automatically. `category`: must be one of `get_allowed_categories()` (defaults to "Text Generation"/"Image Classification", overridable via `MODEL_ALLOWED_CATEGORIES`). `tags`: list of strings. `model_folder_name`: optional explicit folder name for the uploaded model; if omitted, it's derived automatically as before (backward compatible). |
|
|
305
307
|
| **`get_model`** | `model_name`, `local_dest`, `version`, `owner` | Downloads model files. If `version` is omitted, downloads the version marked as 'latest'. `owner` (optional): resolve `model_name` from another user's project, e.g. to download a public model that isn't yours. |
|
|
306
308
|
| **`get_model_info`** | `identifier` (Name or ID), `owner` | Prints detailed info including **Owner**, **Web Link**, Tags, Category, and Version History. `owner` (optional): resolve a name-based `identifier` from another user's project. |
|
|
307
309
|
| **`get_allowed_categories`** | *(none)* | Returns the list of valid `category` values for `add_model`. |
|
|
308
310
|
| **`list_models`** | `verbose`, `category`, `tag`, `public_only`, `private_only`, `include_public` | Lists your own models **plus** (by default, `include_public=True`) every other user's `public=True` model — never another user's private model. Each entry includes a real `owner` field. `verbose=True` shows full JSON metadata. `category`/`tag` filter by exact match; `public_only`/`private_only` filter by visibility, applied across the combined set. `include_public=False` restores the pre-existing own-project-only behavior. When ACL is enabled, results are additionally filtered to models you can access (plus public ones). See "Cross-User Visibility" above. |
|
|
309
|
-
| **`get_model_id_by_name`** | `name`, `owner` | Resolves a model name to its
|
|
310
|
-
| **`get_model_name_by_id`** | `model_id` | Resolves a
|
|
311
|
+
| **`get_model_id_by_name`** | `name`, `owner` | Resolves a model name to its Mlops-Core ID. `owner` (optional): look it up in another user's project instead of your own. |
|
|
312
|
+
| **`get_model_name_by_id`** | `model_id` | Resolves a Mlops-Core ID back to its model name (works for any model, regardless of owner). |
|
|
311
313
|
| **`get_latest_version`** | `model_identifier` (Name or ID) | Returns the version tag currently marked 'latest'. |
|
|
312
314
|
| **`set_latest_version`** | `model_name`, `version` | Updates the 'latest' pointer to a specific version without modifying files. |
|
|
313
315
|
| **`set_model_public`** | `model_name`, `public` | Moves a model's files between the private and public buckets (transactional, parallel server-side copy). |
|
|
@@ -348,12 +350,12 @@ The short version: run `uvicorn main:app --host 0.0.0.0 --port 8080`, pass `Auth
|
|
|
348
350
|
|
|
349
351
|
## Testing
|
|
350
352
|
|
|
351
|
-
- `test_model_registry.py` / `test_ceph_s3_manager.py`: automated unit tests (mock the
|
|
353
|
+
- `test_model_registry.py` / `test_ceph_s3_manager.py`: automated unit tests (mock the Mlops-Core/S3 boundary, no live infra needed). Run with:
|
|
352
354
|
```bash
|
|
353
355
|
pip install pytest
|
|
354
356
|
pytest test_model_registry.py test_ceph_s3_manager.py -v
|
|
355
357
|
```
|
|
356
|
-
- `test.py`: a manual/live-integration demo script covering every scenario above against **real**
|
|
358
|
+
- `test.py`: a manual/live-integration demo script covering every scenario above against **real** Mlops-Core/Ceph/ACL services — everything is commented out by default; uncomment only what you intend to run.
|
|
357
359
|
|
|
358
360
|
---
|
|
359
361
|
|
|
@@ -858,7 +858,9 @@ class CephS3Manager:
|
|
|
858
858
|
with tqdm(total=len(keys), desc="Moving", unit="file", ascii=True, disable=not self.verbose) as pbar:
|
|
859
859
|
for src in keys:
|
|
860
860
|
dest = src.replace(src_prefix, dest_prefix, 1)
|
|
861
|
-
|
|
861
|
+
# s3.copy (not copy_object) — handles files >5GB via
|
|
862
|
+
# automatic multipart copy, same as server_side_copy().
|
|
863
|
+
self.s3.copy({"Bucket": self.bucket_name, "Key": src}, self.bucket_name, dest, Config=self.transfer_config)
|
|
862
864
|
pbar.update(1)
|
|
863
865
|
|
|
864
866
|
self.delete_folder(src_prefix)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import os as _os
|
|
2
|
+
|
|
3
|
+
from .CephS3Manager import CephS3Manager
|
|
4
|
+
from .model_registry import MLOpsManager
|
|
5
|
+
from .update_checker import check_latest_version
|
|
6
|
+
|
|
7
|
+
# The PyPI update check makes a network call; doing that on EVERY import stalls
|
|
8
|
+
# air-gapped/cluster startups. Now opt-in: set AIPMODEL_UPDATE_CHECK=1 to enable.
|
|
9
|
+
if _os.environ.get("AIPMODEL_UPDATE_CHECK", "").lower() in ("1", "true", "yes"):
|
|
10
|
+
check_latest_version("aipmodel")
|
|
11
|
+
|
|
12
|
+
__all__ = ["MLOpsManager", "CephS3Manager"]
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.69"
|
|
15
|
+
__description__ = "SDK for model registration, versioning, and storage"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed SDK exceptions.
|
|
3
|
+
|
|
4
|
+
All subclass ValueError so existing `except ValueError` callers keep working
|
|
5
|
+
(fully backward compatible), while new callers — e.g. the HTTP API — can map
|
|
6
|
+
each type to a precise status code instead of guessing from one generic error.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ModelRegistryError(ValueError):
|
|
11
|
+
"""Base class for all aipmodel errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelNotFoundError(ModelRegistryError):
|
|
15
|
+
"""The requested model / version does not exist."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MetadataError(ModelRegistryError):
|
|
19
|
+
"""The registry (Mlops-Core) rejected the request or returned bad data."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StorageError(ModelRegistryError):
|
|
23
|
+
"""S3 / Ceph operation failed (upload, download, copy, delete)."""
|
|
@@ -18,6 +18,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
18
18
|
|
|
19
19
|
from .CephS3Manager import CephS3Manager
|
|
20
20
|
from .acl_manager import AclManager
|
|
21
|
+
from .exceptions import ModelRegistryError, ModelNotFoundError, MetadataError, StorageError
|
|
21
22
|
|
|
22
23
|
load_dotenv()
|
|
23
24
|
|
|
@@ -57,7 +58,7 @@ class ProjectsAPI:
|
|
|
57
58
|
|
|
58
59
|
response = self._post("/projects.create", {"name": name, "description": description})
|
|
59
60
|
if not response or "id" not in response:
|
|
60
|
-
error_msg = "Failed to create project in
|
|
61
|
+
error_msg = "Failed to create project in Mlops-Core"
|
|
61
62
|
print(f"[SDK_FAIL] {error_msg}")
|
|
62
63
|
raise ValueError(error_msg)
|
|
63
64
|
|
|
@@ -71,7 +72,7 @@ class ProjectsAPI:
|
|
|
71
72
|
|
|
72
73
|
response = self._post("/projects.get_all")
|
|
73
74
|
if not response or "projects" not in response:
|
|
74
|
-
error_msg = "Failed to retrieve projects from
|
|
75
|
+
error_msg = "Failed to retrieve projects from Mlops-Core"
|
|
75
76
|
print(f"[SDK_FAIL] {error_msg}")
|
|
76
77
|
raise ValueError(error_msg)
|
|
77
78
|
|
|
@@ -107,7 +108,7 @@ class ModelsAPI:
|
|
|
107
108
|
|
|
108
109
|
error_msg = f"'models' not found in response"
|
|
109
110
|
print(f"[SDK_ERROR] {error_msg}")
|
|
110
|
-
raise ValueError("Failed to retrieve models from
|
|
111
|
+
raise ValueError("Failed to retrieve models from Mlops-Core")
|
|
111
112
|
|
|
112
113
|
def create(self, name, project_id, metadata=None, uri="", tags=None):
|
|
113
114
|
if self.verbose:
|
|
@@ -126,7 +127,7 @@ class ModelsAPI:
|
|
|
126
127
|
|
|
127
128
|
response = self._post("/models.create", payload)
|
|
128
129
|
if not response or "id" not in response:
|
|
129
|
-
error_msg = "Failed to create model in
|
|
130
|
+
error_msg = "Failed to create model in Mlops-Core"
|
|
130
131
|
print(f"[SDK_FAIL] {error_msg}")
|
|
131
132
|
raise ValueError(error_msg)
|
|
132
133
|
|
|
@@ -213,9 +214,15 @@ def get_user_info_with_bearer(bearer_token: str, user_management_url, timeout: i
|
|
|
213
214
|
headers={"Authorization": f"Bearer {bearer_token}"},
|
|
214
215
|
timeout=timeout,
|
|
215
216
|
)
|
|
217
|
+
if response.status_code in (401, 403):
|
|
218
|
+
# Invalid/expired token is an AUTH failure, not an outage — callers
|
|
219
|
+
# (e.g. the HTTP API) must be able to return 401/403, not 503.
|
|
220
|
+
raise PermissionError(
|
|
221
|
+
f"Authentication failed (HTTP {response.status_code}): {response.text}"
|
|
222
|
+
)
|
|
216
223
|
if response.status_code != 200:
|
|
217
224
|
raise ConnectionError(
|
|
218
|
-
f"
|
|
225
|
+
f"User management error (HTTP {response.status_code}): {response.text}"
|
|
219
226
|
)
|
|
220
227
|
return response.json()
|
|
221
228
|
except requests.RequestException as e:
|
|
@@ -353,14 +360,16 @@ class MLOpsManager:
|
|
|
353
360
|
def __init__(
|
|
354
361
|
self,
|
|
355
362
|
user_token,
|
|
356
|
-
CLEARML_API_HOST=None,
|
|
357
|
-
CLEARML_WEB_HOST=None,
|
|
363
|
+
CLEARML_API_HOST=None, # deprecated alias of MLOPS_CORE_API_HOST (legacy compat)
|
|
364
|
+
CLEARML_WEB_HOST=None, # deprecated alias of MLOPS_CORE_WEB_HOST (legacy compat)
|
|
358
365
|
CEPH_ENDPOINT_URL=None,
|
|
359
366
|
USER_MANAGEMENT_API=None,
|
|
360
367
|
verbose=False,
|
|
361
368
|
skip_connection_check=False,
|
|
362
369
|
ACL_API_HOST=None,
|
|
363
|
-
ACL_ENABLED=None
|
|
370
|
+
ACL_ENABLED=None,
|
|
371
|
+
MLOPS_CORE_API_HOST=None,
|
|
372
|
+
MLOPS_CORE_WEB_HOST=None
|
|
364
373
|
):
|
|
365
374
|
self.verbose = verbose
|
|
366
375
|
if self.verbose:
|
|
@@ -373,13 +382,27 @@ class MLOpsManager:
|
|
|
373
382
|
self.ALLOWED_CATEGORIES = self.resolve_allowed_categories()
|
|
374
383
|
|
|
375
384
|
self.USER_TOKEN = user_token
|
|
376
|
-
|
|
385
|
+
# Preferred configuration: MLOPS_CORE_* kwargs / env vars. The legacy
|
|
386
|
+
# CLEARML_* kwargs and env vars are still accepted as deprecated
|
|
387
|
+
# fallbacks for backward compatibility; when both are given, the new
|
|
388
|
+
# MLOPS_CORE_* name wins.
|
|
389
|
+
self.MLOPS_CORE_API_HOST = (
|
|
390
|
+
MLOPS_CORE_API_HOST
|
|
391
|
+
or CLEARML_API_HOST # deprecated kwarg (legacy compat)
|
|
392
|
+
or os.environ.get("MLOPS_CORE_API_HOST")
|
|
393
|
+
or os.environ.get("CLEARML_API_HOST") # legacy env var fallback
|
|
394
|
+
)
|
|
377
395
|
# Ensure we prioritize specific env var for WEB HOST
|
|
378
|
-
self.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
396
|
+
self.MLOPS_CORE_WEB_HOST = (
|
|
397
|
+
MLOPS_CORE_WEB_HOST
|
|
398
|
+
or CLEARML_WEB_HOST # deprecated kwarg (legacy compat)
|
|
399
|
+
or os.environ.get("MLOPS_CORE_WEB_HOST")
|
|
400
|
+
or os.environ.get("CLEARML_WEB_HOST") # legacy env var fallback
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if not self.MLOPS_CORE_WEB_HOST:
|
|
404
|
+
self.MLOPS_CORE_WEB_HOST = self.MLOPS_CORE_API_HOST
|
|
405
|
+
print(f"[SDK_WARN] MLOPS_CORE_WEB_HOST not set. Defaulting to API host: {self.MLOPS_CORE_API_HOST}")
|
|
383
406
|
print("[SDK_WARN] Web links generated might be incorrect if API and Web UI ports differ.")
|
|
384
407
|
|
|
385
408
|
self.USER_MANAGEMENT_API = USER_MANAGEMENT_API or os.environ.get("USER_MANAGEMENT_API")
|
|
@@ -390,10 +413,10 @@ class MLOpsManager:
|
|
|
390
413
|
self.PUBLIC_ACCESS_KEY = os.environ.get("CEPH_ADMIN_ACCESS_KEY")
|
|
391
414
|
self.PUBLIC_SECRET_KEY = os.environ.get("CEPH_ADMIN_SECRET_KEY")
|
|
392
415
|
|
|
393
|
-
user_info, self.
|
|
416
|
+
user_info, self.MLOPS_CORE_USERNAME = get_user_metadata(bearer_token=user_token, user_management_url=self.USER_MANAGEMENT_API)
|
|
394
417
|
|
|
395
418
|
missing_cfg = [k for k, v in {
|
|
396
|
-
"
|
|
419
|
+
"MLOPS_CORE_API_HOST": self.MLOPS_CORE_API_HOST,
|
|
397
420
|
"USER_MANAGEMENT_API": self.USER_MANAGEMENT_API,
|
|
398
421
|
"CEPH_ENDPOINT_URL": self.CEPH_ENDPOINT_URL,
|
|
399
422
|
}.items() if not v]
|
|
@@ -407,18 +430,20 @@ class MLOpsManager:
|
|
|
407
430
|
self.CEPH_ADMIN_SECRET_KEY = user_info["s3_secret_key"]
|
|
408
431
|
self.CEPH_USER_BUCKET = user_info["s3_bucket"]
|
|
409
432
|
|
|
410
|
-
|
|
411
|
-
|
|
433
|
+
# NOTE: "clearml_access_key" / "clearml_secret_key" are legacy key names
|
|
434
|
+
# from the user-management wire format — do not rename them.
|
|
435
|
+
self.MLOPS_CORE_ACCESS_KEY = user_info["clearml_access_key"]
|
|
436
|
+
self.MLOPS_CORE_SECRET_KEY = user_info["clearml_secret_key"]
|
|
412
437
|
|
|
413
438
|
if not skip_connection_check:
|
|
414
439
|
if self.verbose:
|
|
415
440
|
print("[SDK_INFO] Performing health checks...")
|
|
416
441
|
if not self.check_user_management_service():
|
|
417
442
|
raise ValueError("User Management API unreachable")
|
|
418
|
-
if not self.
|
|
419
|
-
raise ValueError("
|
|
420
|
-
if not self.
|
|
421
|
-
raise ValueError("
|
|
443
|
+
if not self.check_mlops_core_service():
|
|
444
|
+
raise ValueError("Mlops-Core Server down")
|
|
445
|
+
if not self.check_mlops_core_auth():
|
|
446
|
+
raise ValueError("Mlops-Core Auth incorrect")
|
|
422
447
|
if self.verbose:
|
|
423
448
|
print("[SDK_SUCCESS] Health checks passed")
|
|
424
449
|
|
|
@@ -470,29 +495,29 @@ class MLOpsManager:
|
|
|
470
495
|
print("[SDK_INFO] ACL enforcement disabled (bypass mode)")
|
|
471
496
|
|
|
472
497
|
if self.verbose:
|
|
473
|
-
print("[SDK_INFO] Authenticating with
|
|
498
|
+
print("[SDK_INFO] Authenticating with Mlops-Core...")
|
|
474
499
|
|
|
475
|
-
creds = f"{self.
|
|
500
|
+
creds = f"{self.MLOPS_CORE_ACCESS_KEY}:{self.MLOPS_CORE_SECRET_KEY}"
|
|
476
501
|
auth_header = b64encode(creds.encode("utf-8")).decode("utf-8")
|
|
477
502
|
res = requests.post(
|
|
478
|
-
f"{self.
|
|
503
|
+
f"{self.MLOPS_CORE_API_HOST}/auth.login",
|
|
479
504
|
headers={"Authorization": f"Basic {auth_header}"},
|
|
480
505
|
timeout=self.REQUEST_TIMEOUT
|
|
481
506
|
)
|
|
482
507
|
if res.status_code != 200:
|
|
483
|
-
error_msg = "Failed to authenticate with
|
|
508
|
+
error_msg = "Failed to authenticate with Mlops-Core"
|
|
484
509
|
print(f"[SDK_FAIL] {error_msg}")
|
|
485
510
|
raise ValueError(error_msg)
|
|
486
511
|
self.token = res.json()["data"]["token"]
|
|
487
512
|
|
|
488
513
|
if self.verbose:
|
|
489
|
-
print("[SDK_SUCCESS] Authenticated with
|
|
514
|
+
print("[SDK_SUCCESS] Authenticated with Mlops-Core")
|
|
490
515
|
|
|
491
516
|
self.projects = ProjectsAPI(self._post, verbose=self.verbose)
|
|
492
517
|
self.models = ModelsAPI(self._post, verbose=self.verbose)
|
|
493
518
|
|
|
494
519
|
projects = self.projects.get_all()
|
|
495
|
-
self.project_name = f"project_{self.
|
|
520
|
+
self.project_name = f"project_{self.MLOPS_CORE_USERNAME}"
|
|
496
521
|
exists = [p for p in projects if p["name"] == self.project_name]
|
|
497
522
|
self.project_id = exists[0]["id"] if exists else self.projects.create(self.project_name)["id"]
|
|
498
523
|
|
|
@@ -505,12 +530,12 @@ class MLOpsManager:
|
|
|
505
530
|
|
|
506
531
|
headers = {"Authorization": f"Bearer {self.token}"}
|
|
507
532
|
try:
|
|
508
|
-
res = requests.post(f"{self.
|
|
533
|
+
res = requests.post(f"{self.MLOPS_CORE_API_HOST}{path}", headers=headers, json=params, timeout=self.REQUEST_TIMEOUT)
|
|
509
534
|
res.raise_for_status()
|
|
510
535
|
|
|
511
536
|
data = res.json()
|
|
512
537
|
if "data" not in data:
|
|
513
|
-
error_msg = f"Unexpected response from
|
|
538
|
+
error_msg = f"Unexpected response from Mlops-Core {path}: missing 'data' field"
|
|
514
539
|
print(f"[SDK_ERROR] {error_msg}")
|
|
515
540
|
raise ValueError(error_msg)
|
|
516
541
|
|
|
@@ -518,13 +543,18 @@ class MLOpsManager:
|
|
|
518
543
|
|
|
519
544
|
except requests.exceptions.HTTPError as e:
|
|
520
545
|
status = e.response.status_code if (hasattr(e, "response") and e.response is not None) else 0
|
|
521
|
-
error_msg = f"
|
|
546
|
+
error_msg = f"Mlops-Core returned HTTP {status} for {path}: {e}"
|
|
522
547
|
print(f"[SDK_ERROR] {error_msg}")
|
|
523
548
|
if status in (401, 403):
|
|
524
|
-
raise PermissionError(f"
|
|
549
|
+
raise PermissionError(f"Mlops-Core access denied (HTTP {status}) on {path}: {e}") from e
|
|
550
|
+
if 400 <= status < 500:
|
|
551
|
+
# A 4xx is a BAD REQUEST (e.g. nonexistent id) — not an outage.
|
|
552
|
+
# Raising MetadataError (a ValueError) lets callers/API report
|
|
553
|
+
# "not found / invalid" instead of a bogus 503.
|
|
554
|
+
raise MetadataError(error_msg) from e
|
|
525
555
|
raise ConnectionError(error_msg) from e
|
|
526
556
|
except requests.exceptions.RequestException as e:
|
|
527
|
-
error_msg = f"Network error calling
|
|
557
|
+
error_msg = f"Network error calling Mlops-Core {path}: {e}"
|
|
528
558
|
print(f"[SDK_ERROR] {error_msg}")
|
|
529
559
|
raise ConnectionError(error_msg) from e
|
|
530
560
|
except ValueError:
|
|
@@ -546,49 +576,49 @@ class MLOpsManager:
|
|
|
546
576
|
print(f"[SDK_FAIL] User Management API Check Failed: {e}")
|
|
547
577
|
return False
|
|
548
578
|
|
|
549
|
-
def
|
|
579
|
+
def check_mlops_core_service(self):
|
|
550
580
|
try:
|
|
551
|
-
r = requests.get(self.
|
|
581
|
+
r = requests.get(self.MLOPS_CORE_API_HOST + "/auth.login", timeout=5)
|
|
552
582
|
if r.status_code in [200, 401]:
|
|
553
583
|
if self.verbose:
|
|
554
|
-
print("[SDK_SUCCESS]
|
|
584
|
+
print("[SDK_SUCCESS] Mlops-Core Service Reachable")
|
|
555
585
|
return True
|
|
556
586
|
raise ValueError(f"Status Code: {r.status_code}")
|
|
557
587
|
except Exception as e:
|
|
558
588
|
if self.verbose:
|
|
559
|
-
print(f"[SDK_FAIL]
|
|
589
|
+
print(f"[SDK_FAIL] Mlops-Core Service Check Failed: {e}")
|
|
560
590
|
return False
|
|
561
591
|
|
|
562
|
-
def
|
|
592
|
+
def check_mlops_core_auth(self):
|
|
563
593
|
try:
|
|
564
|
-
creds = f"{self.
|
|
594
|
+
creds = f"{self.MLOPS_CORE_ACCESS_KEY}:{self.MLOPS_CORE_SECRET_KEY}"
|
|
565
595
|
auth_header = b64encode(creds.encode("utf-8")).decode("utf-8")
|
|
566
596
|
r = requests.post(
|
|
567
|
-
self.
|
|
597
|
+
self.MLOPS_CORE_API_HOST + "/auth.login",
|
|
568
598
|
headers={"Authorization": f"Basic {auth_header}"},
|
|
569
599
|
timeout=5
|
|
570
600
|
)
|
|
571
601
|
if r.status_code == 200:
|
|
572
602
|
if self.verbose:
|
|
573
|
-
print("[SDK_SUCCESS]
|
|
603
|
+
print("[SDK_SUCCESS] Mlops-Core Auth Valid")
|
|
574
604
|
return True
|
|
575
605
|
raise ValueError(f"Status Code: {r.status_code}")
|
|
576
606
|
except Exception as e:
|
|
577
607
|
if self.verbose:
|
|
578
|
-
print(f"[SDK_FAIL]
|
|
608
|
+
print(f"[SDK_FAIL] Mlops-Core Auth Check Failed: {e}")
|
|
579
609
|
return False
|
|
580
610
|
|
|
581
611
|
def get_model_id_by_name(self, name, owner=None):
|
|
582
612
|
"""
|
|
583
|
-
Resolves a model name to its
|
|
613
|
+
Resolves a model name to its Mlops-Core ID.
|
|
584
614
|
|
|
585
615
|
owner: if omitted, searches your own project only (original behavior).
|
|
586
|
-
If given (a
|
|
616
|
+
If given (a Mlops-Core username), searches that user's project instead -
|
|
587
617
|
use this to resolve a model you don't own, e.g. a public model
|
|
588
618
|
someone else created with the same kind of name.
|
|
589
619
|
"""
|
|
590
620
|
project_id = self.project_id
|
|
591
|
-
if owner and owner != self.
|
|
621
|
+
if owner and owner != self.MLOPS_CORE_USERNAME:
|
|
592
622
|
target_project_name = f"project_{owner}"
|
|
593
623
|
match = next((p for p in self.projects.get_all() if p["name"] == target_project_name), None)
|
|
594
624
|
if not match:
|
|
@@ -616,7 +646,7 @@ class MLOpsManager:
|
|
|
616
646
|
|
|
617
647
|
def _resolve_owner_for_project(self, project_id):
|
|
618
648
|
if project_id == self.project_id:
|
|
619
|
-
return self.
|
|
649
|
+
return self.MLOPS_CORE_USERNAME
|
|
620
650
|
return self._get_project_owner_map().get(project_id, "unknown")
|
|
621
651
|
|
|
622
652
|
def get_model_link(self, model_id):
|
|
@@ -631,7 +661,7 @@ class MLOpsManager:
|
|
|
631
661
|
except Exception:
|
|
632
662
|
pass
|
|
633
663
|
|
|
634
|
-
host = self.
|
|
664
|
+
host = self.MLOPS_CORE_WEB_HOST.rstrip('/')
|
|
635
665
|
link = f"{host}/projects/{project_id}/models/{model_id}"
|
|
636
666
|
|
|
637
667
|
if self.verbose:
|
|
@@ -897,6 +927,7 @@ class MLOpsManager:
|
|
|
897
927
|
temp_local_path = None
|
|
898
928
|
have_model_py = False
|
|
899
929
|
size_mb = 0.0
|
|
930
|
+
moved_to_final = False
|
|
900
931
|
|
|
901
932
|
try:
|
|
902
933
|
if self.verbose:
|
|
@@ -921,11 +952,41 @@ class MLOpsManager:
|
|
|
921
952
|
current_manager.upload(code_path, dest_temp_path + "model.py")
|
|
922
953
|
have_model_py = True
|
|
923
954
|
|
|
955
|
+
# Recovery metadata: a small _meta.json travels WITH the files so a
|
|
956
|
+
# registry entry can be rebuilt from storage alone (see
|
|
957
|
+
# recover_model_from_storage) if the Mlops-Core metadata is ever lost.
|
|
958
|
+
recovery_meta = {
|
|
959
|
+
"model_id": model_id,
|
|
960
|
+
"model_name": model_name,
|
|
961
|
+
"version": version,
|
|
962
|
+
"version_number": next_version_number,
|
|
963
|
+
"category": category,
|
|
964
|
+
"tags": final_tags,
|
|
965
|
+
"owner": self.MLOPS_CORE_USERNAME,
|
|
966
|
+
"public": public,
|
|
967
|
+
"folderName": model_folder_name,
|
|
968
|
+
"haveModelPy": have_model_py,
|
|
969
|
+
"size": size_mb,
|
|
970
|
+
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
971
|
+
}
|
|
972
|
+
try:
|
|
973
|
+
current_manager.s3.put_object(
|
|
974
|
+
Bucket=current_manager.bucket_name,
|
|
975
|
+
Key=dest_temp_path + "_meta.json",
|
|
976
|
+
Body=json.dumps(recovery_meta).encode("utf-8"),
|
|
977
|
+
ContentType="application/json",
|
|
978
|
+
)
|
|
979
|
+
except Exception as e:
|
|
980
|
+
# best-effort: never fail an add because of the recovery sidecar
|
|
981
|
+
if self.verbose:
|
|
982
|
+
print(f"[SDK_WARN] Could not write recovery _meta.json: {e}")
|
|
983
|
+
|
|
924
984
|
if current_manager.check_if_exists(dest_version_path):
|
|
925
985
|
print(f"[SDK_WARN] Version {version} exists. Overwriting...")
|
|
926
986
|
current_manager.delete_folder(dest_version_path)
|
|
927
|
-
|
|
987
|
+
|
|
928
988
|
current_manager.move_folder(dest_temp_path, dest_version_path)
|
|
989
|
+
moved_to_final = True # from here on, rollback must clean dest_version_path too
|
|
929
990
|
|
|
930
991
|
model_data = self.models.get_by_id(model_id)
|
|
931
992
|
metadata = model_data.get("metadata", {})
|
|
@@ -985,11 +1046,29 @@ class MLOpsManager:
|
|
|
985
1046
|
print("[SDK_INFO] Rolled back new model creation")
|
|
986
1047
|
except Exception:
|
|
987
1048
|
pass
|
|
1049
|
+
# ACL was registered before upload — roll it back too so the ACL
|
|
1050
|
+
# service doesn't keep a record of a model that no longer exists.
|
|
1051
|
+
if is_new_model and self.acl:
|
|
1052
|
+
try:
|
|
1053
|
+
self.acl.delete_model(model_id)
|
|
1054
|
+
except Exception:
|
|
1055
|
+
pass
|
|
988
1056
|
if dest_temp_path and "current_manager" in locals():
|
|
989
1057
|
try:
|
|
990
1058
|
current_manager.delete_folder(dest_temp_path)
|
|
991
1059
|
except Exception:
|
|
992
1060
|
pass
|
|
1061
|
+
# If files were already moved to the FINAL path but the metadata
|
|
1062
|
+
# write failed, those files would be invisible zombies (metadata
|
|
1063
|
+
# doesn't reference them and the *_tmp_* sweep won't find them).
|
|
1064
|
+
# Delete them so registry and S3 stay consistent.
|
|
1065
|
+
if moved_to_final and "current_manager" in locals():
|
|
1066
|
+
try:
|
|
1067
|
+
current_manager.delete_folder(dest_version_path)
|
|
1068
|
+
if self.verbose:
|
|
1069
|
+
print("[SDK_INFO] Rolled back uploaded files from final path")
|
|
1070
|
+
except Exception:
|
|
1071
|
+
pass
|
|
993
1072
|
raise
|
|
994
1073
|
finally:
|
|
995
1074
|
for path in [local_path, temp_local_path]:
|
|
@@ -1207,7 +1286,7 @@ class MLOpsManager:
|
|
|
1207
1286
|
|
|
1208
1287
|
# 2. If not found by name, assume it might be an ID
|
|
1209
1288
|
if not model_id:
|
|
1210
|
-
# Simple regex check for
|
|
1289
|
+
# Simple regex check for Mlops-Core ID format (32 hex chars) just to be safe,
|
|
1211
1290
|
# or directly attempt to use it as ID.
|
|
1212
1291
|
if re.match(r'^[a-f0-9]{32}$', model_identifier):
|
|
1213
1292
|
model_id = model_identifier
|
|
@@ -1216,8 +1295,10 @@ class MLOpsManager:
|
|
|
1216
1295
|
|
|
1217
1296
|
try:
|
|
1218
1297
|
model_data = self.models.get_by_id(model_id)
|
|
1298
|
+
except (PermissionError, ConnectionError):
|
|
1299
|
+
raise # outage/auth must NOT be reported as "not found"
|
|
1219
1300
|
except Exception:
|
|
1220
|
-
|
|
1301
|
+
raise ModelNotFoundError(f"Model data retrieval failed for ID: {model_id}")
|
|
1221
1302
|
|
|
1222
1303
|
metadata = model_data.get("metadata", {})
|
|
1223
1304
|
latest_version = self._extract_metadata_value(metadata, "latest_version")
|
|
@@ -1253,8 +1334,10 @@ class MLOpsManager:
|
|
|
1253
1334
|
|
|
1254
1335
|
try:
|
|
1255
1336
|
data = self.models.get_by_id(model_id)
|
|
1337
|
+
except (PermissionError, ConnectionError):
|
|
1338
|
+
raise # outage/auth must NOT be reported as "not found"
|
|
1256
1339
|
except Exception:
|
|
1257
|
-
|
|
1340
|
+
raise ModelNotFoundError(f"No model found with identifier: '{identifier}'")
|
|
1258
1341
|
|
|
1259
1342
|
m = data
|
|
1260
1343
|
metadata = m.get("metadata", {})
|
|
@@ -1440,7 +1523,7 @@ class MLOpsManager:
|
|
|
1440
1523
|
print(f"{name:<25} | {mid:<25} | {latest:<10} | {public_display:<6} | {str(total_size):<10} | {display_versions}")
|
|
1441
1524
|
|
|
1442
1525
|
owner_username = (
|
|
1443
|
-
self.
|
|
1526
|
+
self.MLOPS_CORE_USERNAME if mid in own_ids
|
|
1444
1527
|
else project_owner_map.get(m.get("project"), "unknown")
|
|
1445
1528
|
)
|
|
1446
1529
|
model_entry = {
|
|
@@ -1504,15 +1587,19 @@ class MLOpsManager:
|
|
|
1504
1587
|
path_to_delete = versions_map[version].get("path")
|
|
1505
1588
|
if not path_to_delete:
|
|
1506
1589
|
raise ValueError(f"Version '{version}' of model '{model_name or model_id}' has no stored path — metadata may be corrupted")
|
|
1507
|
-
|
|
1590
|
+
# Metadata-FIRST ordering: update the registry before deleting S3
|
|
1591
|
+
# files. If we crash in between, the worst case is an orphan S3
|
|
1592
|
+
# folder (harmless, discoverable by verify tooling) — never a
|
|
1593
|
+
# registry entry pointing at deleted files (a zombie the dashboard
|
|
1594
|
+
# would render as a broken model).
|
|
1508
1595
|
del versions_map[version]
|
|
1509
|
-
|
|
1596
|
+
|
|
1510
1597
|
if not versions_map:
|
|
1511
1598
|
print(f"[SDK_INFO] No versions left for model. Deleting entire model...")
|
|
1512
|
-
ceph_manager.delete_folder(f"_models/{model_id}/")
|
|
1513
1599
|
self.models.delete(model_id)
|
|
1514
1600
|
if self.acl:
|
|
1515
1601
|
self.acl.delete_model(model_id)
|
|
1602
|
+
ceph_manager.delete_folder(f"_models/{model_id}/")
|
|
1516
1603
|
print(f"[SDK_SUCCESS] Model '{model_name or model_id}' completely deleted.")
|
|
1517
1604
|
return
|
|
1518
1605
|
else:
|
|
@@ -1546,12 +1633,216 @@ class MLOpsManager:
|
|
|
1546
1633
|
{"key": "haveModelPy", "type": "str", "value": versions_map[new_latest].get("haveModelPy", "false") if new_latest in versions_map else "false"}
|
|
1547
1634
|
]
|
|
1548
1635
|
self.models.update_metadata(model_id, metadata=new_metadata)
|
|
1549
|
-
|
|
1636
|
+
|
|
1637
|
+
# S3 delete LAST (see metadata-first note above).
|
|
1638
|
+
ceph_manager.delete_folder(path_to_delete)
|
|
1639
|
+
|
|
1550
1640
|
print(f"[SDK_SUCCESS] Version {version} deleted.")
|
|
1551
1641
|
|
|
1552
1642
|
else:
|
|
1553
|
-
|
|
1643
|
+
# Metadata-first here too: registry entry goes before the files.
|
|
1554
1644
|
self.models.delete(model_id)
|
|
1555
1645
|
if self.acl:
|
|
1556
1646
|
self.acl.delete_model(model_id)
|
|
1557
|
-
|
|
1647
|
+
ceph_manager.delete_folder(f"_models/{model_id}/")
|
|
1648
|
+
print(f"[SDK_SUCCESS] Model '{model_id}' deleted completely.")
|
|
1649
|
+
|
|
1650
|
+
# ------------------------------------------------------------------
|
|
1651
|
+
# Registry <-> Storage consistency (zombie/orphan detection & recovery)
|
|
1652
|
+
# ------------------------------------------------------------------
|
|
1653
|
+
|
|
1654
|
+
def verify_model_integrity(self, model_name=None, model_id=None):
|
|
1655
|
+
"""
|
|
1656
|
+
Cross-check one model between the registry (Mlops-Core) and S3.
|
|
1657
|
+
|
|
1658
|
+
Returns:
|
|
1659
|
+
{
|
|
1660
|
+
"model_id", "model_name", "status": "ok"|"missing_files"|"orphan_files",
|
|
1661
|
+
"versions": [{"version","path","files_exist","status"}...],
|
|
1662
|
+
"orphan_folders": [<S3 folders under the model not referenced by any version>],
|
|
1663
|
+
}
|
|
1664
|
+
Raises ModelNotFoundError if the model is not in the registry at all.
|
|
1665
|
+
"""
|
|
1666
|
+
if not model_id:
|
|
1667
|
+
model_id = self.get_model_id_by_name(model_name)
|
|
1668
|
+
if not model_id:
|
|
1669
|
+
raise ModelNotFoundError(f"Model not found: '{model_name}'")
|
|
1670
|
+
data = self.models.get_by_id(model_id)
|
|
1671
|
+
metadata = data.get("metadata", {})
|
|
1672
|
+
versions_map = self._parse_versions_map(metadata)
|
|
1673
|
+
is_public = self._extract_metadata_value(metadata, "public", "false").lower() == "true"
|
|
1674
|
+
mgr = self._get_ceph_manager(is_public)
|
|
1675
|
+
|
|
1676
|
+
report = {
|
|
1677
|
+
"model_id": model_id,
|
|
1678
|
+
"model_name": data.get("name"),
|
|
1679
|
+
"status": "ok",
|
|
1680
|
+
"versions": [],
|
|
1681
|
+
"orphan_folders": [],
|
|
1682
|
+
}
|
|
1683
|
+
referenced_dirs = set()
|
|
1684
|
+
for ver, info in versions_map.items():
|
|
1685
|
+
if not isinstance(info, dict):
|
|
1686
|
+
continue
|
|
1687
|
+
path = info.get("path") or ""
|
|
1688
|
+
if path:
|
|
1689
|
+
referenced_dirs.add(path.rstrip("/").split("/")[-1])
|
|
1690
|
+
files_exist = bool(path) and mgr.check_if_exists(path)
|
|
1691
|
+
report["versions"].append({
|
|
1692
|
+
"version": ver,
|
|
1693
|
+
"path": path,
|
|
1694
|
+
"files_exist": files_exist,
|
|
1695
|
+
"status": "ok" if files_exist else "missing_files",
|
|
1696
|
+
})
|
|
1697
|
+
|
|
1698
|
+
# S3 folders under this model that no registry version references
|
|
1699
|
+
base = f"_models/{model_id}/"
|
|
1700
|
+
tops = set()
|
|
1701
|
+
try:
|
|
1702
|
+
keys = mgr.list_folder_contents(base) if mgr.check_if_exists(base) else []
|
|
1703
|
+
for k in keys:
|
|
1704
|
+
rel = k[len(base):] if k.startswith(base) else k
|
|
1705
|
+
top = rel.split("/", 1)[0]
|
|
1706
|
+
if top and top != "_meta.json":
|
|
1707
|
+
tops.add(top)
|
|
1708
|
+
except Exception:
|
|
1709
|
+
pass # storage listing unavailable -> report only version checks
|
|
1710
|
+
report["orphan_folders"] = sorted(t for t in tops if t not in referenced_dirs)
|
|
1711
|
+
|
|
1712
|
+
if any(v["status"] != "ok" for v in report["versions"]):
|
|
1713
|
+
report["status"] = "missing_files"
|
|
1714
|
+
elif report["orphan_folders"]:
|
|
1715
|
+
report["status"] = "orphan_files"
|
|
1716
|
+
return report
|
|
1717
|
+
|
|
1718
|
+
def verify_all_models(self):
|
|
1719
|
+
"""Run verify_model_integrity over every model in the user's project.
|
|
1720
|
+
Returns a list of per-model reports (never raises per-model; a model
|
|
1721
|
+
that fails to verify is reported with status "error")."""
|
|
1722
|
+
reports = []
|
|
1723
|
+
models = self.models.get_all(self.project_id) or []
|
|
1724
|
+
for m in models:
|
|
1725
|
+
mid = m.get("id")
|
|
1726
|
+
try:
|
|
1727
|
+
reports.append(self.verify_model_integrity(model_id=mid))
|
|
1728
|
+
except (PermissionError, ConnectionError):
|
|
1729
|
+
raise
|
|
1730
|
+
except Exception as e:
|
|
1731
|
+
reports.append({
|
|
1732
|
+
"model_id": mid, "model_name": m.get("name"),
|
|
1733
|
+
"status": "error", "error": str(e),
|
|
1734
|
+
"versions": [], "orphan_folders": [],
|
|
1735
|
+
})
|
|
1736
|
+
return reports
|
|
1737
|
+
|
|
1738
|
+
def find_orphan_models(self):
|
|
1739
|
+
"""
|
|
1740
|
+
Find S3 model folders (under `_models/`) that the registry does NOT
|
|
1741
|
+
know about — e.g. leftovers after a registry (Mlops-Core) data loss.
|
|
1742
|
+
Returns [{"model_id", "recoverable" (has _meta.json), "meta": {...}|None}].
|
|
1743
|
+
"""
|
|
1744
|
+
mgr = self._get_ceph_manager(False)
|
|
1745
|
+
registry_ids = {m.get("id") for m in (self.models.get_all(self.project_id) or [])}
|
|
1746
|
+
s3_ids = set()
|
|
1747
|
+
try:
|
|
1748
|
+
for k in mgr.list_folder_contents("_models/"):
|
|
1749
|
+
rel = k[len("_models/"):] if k.startswith("_models/") else k
|
|
1750
|
+
top = rel.split("/", 1)[0]
|
|
1751
|
+
if top:
|
|
1752
|
+
s3_ids.add(top)
|
|
1753
|
+
except Exception:
|
|
1754
|
+
pass
|
|
1755
|
+
orphans = []
|
|
1756
|
+
for mid in sorted(s3_ids - registry_ids):
|
|
1757
|
+
meta = self._read_first_meta_json(mgr, mid)
|
|
1758
|
+
orphans.append({"model_id": mid, "recoverable": meta is not None, "meta": meta})
|
|
1759
|
+
return orphans
|
|
1760
|
+
|
|
1761
|
+
def _read_first_meta_json(self, mgr, model_id):
|
|
1762
|
+
"""Read any one version's recovery _meta.json for a model, else None."""
|
|
1763
|
+
try:
|
|
1764
|
+
for k in mgr.list_folder_contents(f"_models/{model_id}/"):
|
|
1765
|
+
if k.endswith("/_meta.json"):
|
|
1766
|
+
try:
|
|
1767
|
+
obj = mgr.s3.get_object(Bucket=mgr.bucket_name, Key=k)
|
|
1768
|
+
return json.loads(obj["Body"].read().decode("utf-8"))
|
|
1769
|
+
except Exception:
|
|
1770
|
+
continue
|
|
1771
|
+
except Exception:
|
|
1772
|
+
pass
|
|
1773
|
+
return None
|
|
1774
|
+
|
|
1775
|
+
def recover_model_from_storage(self, model_id):
|
|
1776
|
+
"""
|
|
1777
|
+
Rebuild a registry (Mlops-Core) entry for a model that exists in S3 but
|
|
1778
|
+
not in the registry, using the per-version `_meta.json` sidecar files
|
|
1779
|
+
written by add_model. This is the disaster-recovery path when the
|
|
1780
|
+
registry's data is lost while S3 survives.
|
|
1781
|
+
|
|
1782
|
+
Returns the new registry model id.
|
|
1783
|
+
"""
|
|
1784
|
+
mgr = self._get_ceph_manager(False)
|
|
1785
|
+
base = f"_models/{model_id}/"
|
|
1786
|
+
try:
|
|
1787
|
+
keys = mgr.list_folder_contents(base)
|
|
1788
|
+
except Exception as e:
|
|
1789
|
+
raise StorageError(f"Cannot list storage for model {model_id}: {e}")
|
|
1790
|
+
# collect every version folder + its _meta.json
|
|
1791
|
+
metas = {}
|
|
1792
|
+
for k in keys:
|
|
1793
|
+
if k.endswith("/_meta.json"):
|
|
1794
|
+
try:
|
|
1795
|
+
obj = mgr.s3.get_object(Bucket=mgr.bucket_name, Key=k)
|
|
1796
|
+
meta = json.loads(obj["Body"].read().decode("utf-8"))
|
|
1797
|
+
metas[meta.get("version")] = (k, meta)
|
|
1798
|
+
except Exception:
|
|
1799
|
+
continue
|
|
1800
|
+
if not metas:
|
|
1801
|
+
raise StorageError(
|
|
1802
|
+
f"No recovery _meta.json found under {base} — this model predates "
|
|
1803
|
+
f"recovery sidecars and must be re-registered manually."
|
|
1804
|
+
)
|
|
1805
|
+
any_meta = next(iter(metas.values()))[1]
|
|
1806
|
+
name = any_meta.get("model_name") or f"recovered_{model_id[:8]}"
|
|
1807
|
+
if self.get_model_id_by_name(name):
|
|
1808
|
+
raise ValueError(f"A model named '{name}' already exists in the registry — refusing to overwrite.")
|
|
1809
|
+
|
|
1810
|
+
tags = any_meta.get("tags") or []
|
|
1811
|
+
category = any_meta.get("category")
|
|
1812
|
+
created_model = self.models.create(name=name, project_id=self.project_id, uri="s3://placeholder", tags=tags)
|
|
1813
|
+
new_id = created_model["id"]
|
|
1814
|
+
|
|
1815
|
+
versions_map, latest, latest_num = {}, None, -1
|
|
1816
|
+
for ver, (key, meta) in metas.items():
|
|
1817
|
+
old_prefix = key[: -len("_meta.json")] # _models/<old_id>/<ver>/
|
|
1818
|
+
versions_map[ver] = {
|
|
1819
|
+
"path": old_prefix,
|
|
1820
|
+
"size": meta.get("size", 0.0),
|
|
1821
|
+
"created": meta.get("created", ""),
|
|
1822
|
+
"haveModelPy": str(meta.get("haveModelPy", False)).lower(),
|
|
1823
|
+
"folderName": meta.get("folderName", ""),
|
|
1824
|
+
"version_number": meta.get("version_number", 0),
|
|
1825
|
+
}
|
|
1826
|
+
try:
|
|
1827
|
+
vn = int(meta.get("version_number", 0))
|
|
1828
|
+
except Exception:
|
|
1829
|
+
vn = 0
|
|
1830
|
+
if vn >= latest_num:
|
|
1831
|
+
latest_num, latest = vn, ver
|
|
1832
|
+
|
|
1833
|
+
total_size = sum(float(v.get("size") or 0) for v in versions_map.values())
|
|
1834
|
+
new_metadata = [
|
|
1835
|
+
{"key": "versions_map", "type": "str", "value": json.dumps(versions_map)},
|
|
1836
|
+
{"key": "latest_version", "type": "str", "value": latest or ""},
|
|
1837
|
+
{"key": "modelSize", "type": "float", "value": f"{total_size:.2f}"},
|
|
1838
|
+
{"key": "version_count", "type": "int", "value": str(len(versions_map))},
|
|
1839
|
+
{"key": "public", "type": "bool", "value": str(any_meta.get("public", False)).lower()},
|
|
1840
|
+
{"key": "recovered_from", "type": "str", "value": model_id},
|
|
1841
|
+
]
|
|
1842
|
+
if category:
|
|
1843
|
+
new_metadata.append({"key": "category", "type": "str", "value": category})
|
|
1844
|
+
uri = f"s3://{mgr.bucket_name}/{versions_map[latest]['path']}" if latest else "s3://placeholder"
|
|
1845
|
+
self.models.update_info(new_id, uri=uri, tags=tags)
|
|
1846
|
+
self.models.update_metadata(new_id, metadata=new_metadata)
|
|
1847
|
+
print(f"[SDK_SUCCESS] Recovered model '{name}' ({len(versions_map)} versions) as registry id {new_id} (files remain at {base}).")
|
|
1848
|
+
return new_id
|
|
@@ -11,7 +11,7 @@ print("\n[STEP 1] Initialize MLOps Manager")
|
|
|
11
11
|
# verbose=True enables detailed logging (Transparency Mode)
|
|
12
12
|
manager = MLOpsManager(
|
|
13
13
|
user_token=os.getenv("USER_TOKEN"),
|
|
14
|
-
|
|
14
|
+
MLOPS_CORE_API_HOST=os.getenv("MLOPS_CORE_API_HOST"),
|
|
15
15
|
CEPH_ENDPOINT_URL=os.getenv("CEPH_ENDPOINT_URL"),
|
|
16
16
|
USER_MANAGEMENT_API=os.getenv("USER_MANAGEMENT_API"),
|
|
17
17
|
verbose=True
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
from .CephS3Manager import CephS3Manager
|
|
2
|
-
from .model_registry import MLOpsManager
|
|
3
|
-
from .update_checker import check_latest_version
|
|
4
|
-
|
|
5
|
-
check_latest_version("aipmodel")
|
|
6
|
-
|
|
7
|
-
__all__ = ["MLOpsManager", "CephS3Manager"]
|
|
8
|
-
|
|
9
|
-
__version__ = "0.2.68"
|
|
10
|
-
__description__ = "SDK for model registration, versioning, and storage"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|