diff --git a/packages/data_designer_nemo/src/data_designer_nemo/secret_resolver.py b/packages/data_designer_nemo/src/data_designer_nemo/secret_resolver.py index 914107270b..5b281a1b8e 100644 --- a/packages/data_designer_nemo/src/data_designer_nemo/secret_resolver.py +++ b/packages/data_designer_nemo/src/data_designer_nemo/secret_resolver.py @@ -6,7 +6,10 @@ import anyio.from_thread from data_designer.engine.errors import SecretResolutionError from data_designer_nemo.errors import NDDInternalError, NDDInvalidConfigError -from nemo_platform import AsyncNeMoPlatform, NeMoPlatform, NotFoundError, PermissionDeniedError +from nemo_platform import AsyncNeMoPlatform, NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.client.errors import NotFoundError, PermissionDeniedError +from nemo_platform_plugin.secrets.client import AsyncSecretsClient, SecretsClient logger = logging.getLogger(__name__) @@ -20,8 +23,9 @@ async def validate_secret(sdk: AsyncNeMoPlatform, secret: str, default_workspace NMPSecretResolver). """ workspace, name = _parse_secret_reference(secret, default_workspace) + secrets = client_from_platform(sdk, AsyncSecretsClient) try: - await sdk.secrets.access(name=name, workspace=workspace) + await secrets.access_secret(name=name, workspace=workspace) except NotFoundError as e: raise NDDInvalidConfigError(f"Could not find secret {name!r} in workspace {workspace!r}") from e except PermissionDeniedError as e: @@ -58,11 +62,14 @@ def resolve(self, secret: str) -> str: workspace, name = _parse_secret_reference(secret, self._default_workspace) if isinstance(self._sdk, AsyncNeMoPlatform): # ``anyio.from_thread.run`` only forwards positional args, so wrap the - # kwargs-only SDK call in a no-arg coroutine factory. - async_sdk = self._sdk - result = anyio.from_thread.run(lambda: async_sdk.secrets.access(name=name, workspace=workspace)) + # kwargs-only client call in a no-arg coroutine factory. + async_secrets = client_from_platform(self._sdk, AsyncSecretsClient) + result = anyio.from_thread.run( + lambda: async_secrets.access_secret(name=name, workspace=workspace) + ).data() else: - result = self._sdk.secrets.access(name=name, workspace=workspace) + secrets = client_from_platform(self._sdk, SecretsClient) + result = secrets.access_secret(name=name, workspace=workspace).data() return result.value except Exception as e: raise SecretResolutionError(f"Error resolving secret {secret!r}: {e}") from e diff --git a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/setup.py b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/setup.py index 860d4b3bf7..5133eafe4d 100644 --- a/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/setup.py +++ b/packages/nemo_platform_ext/src/nemo_platform_ext/cli/commands/setup.py @@ -26,7 +26,11 @@ import typer import yaml as _yaml from nemo_platform import NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest, PlatformSecretUpdateRequest from nmp.common.config import nmp_user_data_dir +from pydantic import SecretStr from rich import box from rich.console import Console from rich.panel import Panel @@ -385,19 +389,22 @@ def _provider_exists(client: NeMoPlatform, name: str, workspace: str) -> bool: def _secret_exists(client: NeMoPlatform, name: str, workspace: str) -> bool: """Return True if a secret with *name* already exists.""" + secrets = client_from_platform(client, SecretsClient) try: - client.secrets.retrieve(name, workspace=workspace) + secrets.get_secret(name=name, workspace=workspace) return True except Exception: return False def _create_secret(client: NeMoPlatform, name: str, value: str, workspace: str) -> None: - client.secrets.create(name=name, value=value, workspace=workspace) + secrets = client_from_platform(client, SecretsClient) + secrets.create_secret(body=PlatformSecretCreateRequest(name=name, value=SecretStr(value)), workspace=workspace) def _update_secret(client: NeMoPlatform, name: str, value: str, workspace: str) -> None: - client.secrets.update(name, value=value, workspace=workspace) + secrets = client_from_platform(client, SecretsClient) + secrets.update_secret(name=name, body=PlatformSecretUpdateRequest(value=SecretStr(value)), workspace=workspace) def _create_provider( diff --git a/packages/nemo_platform_ext/tests/cli/commands/test_setup.py b/packages/nemo_platform_ext/tests/cli/commands/test_setup.py index e3c67caa7d..a911b1a3a1 100644 --- a/packages/nemo_platform_ext/tests/cli/commands/test_setup.py +++ b/packages/nemo_platform_ext/tests/cli/commands/test_setup.py @@ -77,6 +77,8 @@ Context, ContextDefinition, ) +from nemo_platform_plugin.client.errors import NotFoundError +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest, PlatformSecretUpdateRequest SETUP_MOD = "nemo_platform_ext.cli.commands.setup" @@ -241,14 +243,39 @@ def test_ollama_not_running(self): # --------------------------------------------------------------------------- +def _not_found_error() -> NotFoundError: + """Build a NotFoundError backed by a mock 404 response for `get_secret`.""" + resp = MagicMock(spec=httpx.Response) + resp.status_code = 404 + resp.json.side_effect = ValueError("no body") + resp.text = "not found" + resp.reason_phrase = "Not Found" + return NotFoundError(resp) + + +def _make_mock_secrets_client(*, secret_exists: bool = False) -> MagicMock: + """Build a mock typed SecretsClient with get/create/update as MagicMocks.""" + secrets = MagicMock() + secrets.get_secret = MagicMock() + secrets.create_secret = MagicMock() + secrets.update_secret = MagicMock() + if secret_exists: + secrets.get_secret.return_value = MagicMock() + else: + secrets.get_secret.side_effect = _not_found_error() + return secrets + + def _make_mock_client(*, provider_exists: bool = False, secret_exists: bool = False) -> MagicMock: - """Build a mock NeMoPlatform client with configurable provider/secret state.""" + """Build a mock NeMoPlatform client with configurable provider/secret state. + + The typed secrets client returned by ``client_from_platform`` (patched via + the ``_patch_secrets_client`` fixture) is stashed on ``client.mock_secrets`` + so tests can assert on ``get_secret``/``create_secret``/``update_secret``. + """ client = MagicMock() client.inference.providers = MagicMock(spec=ProvidersResource) - if secret_exists: - client.secrets.retrieve.return_value = MagicMock() - else: - client.secrets.retrieve.side_effect = Exception("not found") + client.mock_secrets = _make_mock_secrets_client(secret_exists=secret_exists) if provider_exists: client.inference.providers.retrieve.return_value = MagicMock() else: @@ -256,6 +283,29 @@ def _make_mock_client(*, provider_exists: bool = False, secret_exists: bool = Fa return client +@pytest.fixture(autouse=True) +def _patch_secrets_client(): + """Route ``client_from_platform(client, SecretsClient)`` to ``client.mock_secrets``. + + The secrets helpers in setup.py obtain a typed ``SecretsClient`` via + ``client_from_platform``. Real adaptation would run against a MagicMock and + blow up in ``raise_for_status``, so tests patch it to return the mock secrets + client attached to the mock platform client (falling back to a fresh mock for + plain ``MagicMock()`` clients that lack ``mock_secrets``). + """ + + def _resolve(client, _client_cls): + secrets = getattr(client, "mock_secrets", None) + if isinstance(secrets, MagicMock): + return secrets + fallback = _make_mock_secrets_client() + client.mock_secrets = fallback + return fallback + + with patch(f"{SETUP_MOD}.client_from_platform", side_effect=_resolve): + yield + + # --------------------------------------------------------------------------- # Provider creation # --------------------------------------------------------------------------- @@ -343,7 +393,7 @@ def test_openai_key_creates_provider(self): with patch.dict("os.environ", {"OPENAI_API_KEY": "sk-test123"}, clear=True): result = _auto_setup(client, "default") assert result is True - client.secrets.create.assert_called_once() + client.mock_secrets.create_secret.assert_called_once() client.inference.providers.create.assert_called_once() create_kwargs = client.inference.providers.create.call_args assert create_kwargs.kwargs["name"] == "openai" @@ -1188,10 +1238,13 @@ def test_existing_secret_gets_updated_with_new_value(self): api_key="new-key-value", workspace="default", ) - client.secrets.update.assert_called_once_with( - "nvidia-build-api-key", value="new-key-value", workspace="default" - ) - client.secrets.create.assert_not_called() + client.mock_secrets.update_secret.assert_called_once() + update_call = client.mock_secrets.update_secret.call_args + assert update_call.kwargs["name"] == "nvidia-build-api-key" + assert update_call.kwargs["workspace"] == "default" + assert isinstance(update_call.kwargs["body"], PlatformSecretUpdateRequest) + assert update_call.kwargs["body"].value.get_secret_value() == "new-key-value" + client.mock_secrets.create_secret.assert_not_called() client.inference.providers.create.assert_called_once() client.inference.providers.update.assert_not_called() @@ -1205,8 +1258,13 @@ def test_existing_provider_gets_updated_with_secret(self): api_key="my-key", workspace="default", ) - client.secrets.create.assert_called_once_with(name="nvidia-build-api-key", value="my-key", workspace="default") - client.secrets.update.assert_not_called() + client.mock_secrets.create_secret.assert_called_once() + create_call = client.mock_secrets.create_secret.call_args + assert create_call.kwargs["workspace"] == "default" + assert isinstance(create_call.kwargs["body"], PlatformSecretCreateRequest) + assert create_call.kwargs["body"].name == "nvidia-build-api-key" + assert create_call.kwargs["body"].value.get_secret_value() == "my-key" + client.mock_secrets.update_secret.assert_not_called() client.inference.providers.update.assert_called_once() call_kwargs = client.inference.providers.update.call_args.kwargs assert call_kwargs["api_key_secret_name"] == "nvidia-build-api-key" @@ -1223,9 +1281,9 @@ def test_fresh_install_creates_both(self): api_key="sk-test", workspace="default", ) - client.secrets.create.assert_called_once() + client.mock_secrets.create_secret.assert_called_once() client.inference.providers.create.assert_called_once() - client.secrets.update.assert_not_called() + client.mock_secrets.update_secret.assert_not_called() client.inference.providers.update.assert_not_called() def test_existing_provider_updated_with_extra_headers(self): @@ -1252,7 +1310,7 @@ def test_auto_setup_updates_existing_provider_secret_binding(self): with patch.dict("os.environ", {"OPENAI_API_KEY": "sk-new-key"}, clear=True): result = _auto_setup(client, "default") assert result is True - client.secrets.create.assert_called_once() + client.mock_secrets.create_secret.assert_called_once() client.inference.providers.update.assert_called_once() call_kwargs = client.inference.providers.update.call_args.kwargs assert call_kwargs["api_key_secret_name"] == "openai-api-key" @@ -1263,8 +1321,13 @@ def test_auto_setup_updates_existing_secret_value(self): with patch.dict("os.environ", {"NVIDIA_API_KEY": "nvapi-new"}, clear=True): result = _auto_setup(client, "default") assert result is True - client.secrets.update.assert_called_once_with("nvidia-build-api-key", value="nvapi-new", workspace="default") - client.secrets.create.assert_not_called() + client.mock_secrets.update_secret.assert_called_once() + update_call = client.mock_secrets.update_secret.call_args + assert update_call.kwargs["name"] == "nvidia-build-api-key" + assert update_call.kwargs["workspace"] == "default" + assert isinstance(update_call.kwargs["body"], PlatformSecretUpdateRequest) + assert update_call.kwargs["body"].value.get_secret_value() == "nvapi-new" + client.mock_secrets.create_secret.assert_not_called() # --------------------------------------------------------------------------- diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/MIGRATION.md b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/MIGRATION.md new file mode 100644 index 0000000000..15fa0adac0 --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/client/MIGRATION.md @@ -0,0 +1,515 @@ +# Migrating a core service to NemoClient + +A reusable playbook for moving a NeMo Platform service off the Stainless-generated +`NeMoPlatform` SDK and onto the typed `NemoClient` HTTP client. + +Tracking issue: **AIRCORE-827** (parent). First reference migration: **Files/Filesets** +(PR #429, follow-ups #539/#576). This doc was written while doing **Secrets** +(AIRCORE-873) as the second migration; where it says "for Secrets" treat that as a +worked example of the general steps. + +> **Status of shared infra:** stable. The core client (`nemo_platform_plugin/client/`) +> — decorators, `send()`, pagination, retry, errors, auth/OIDC, the +> `client_from_platform` adapter — already exists and is exercised by Files. Per-service +> work is almost entirely `types.py` + `endpoints.py` + `client.py` + tests. + +--- + +## 1. What "migrate to NemoClient" actually means + +| | Stainless SDK (`NeMoPlatform`) | NemoClient | +| --- | --- | --- | +| Where it comes from | Generated by Stainless from `openapi.yaml`, vendored under `sdk/python/nemo-platform/` | Hand-written typed Python in `nemo_platform_plugin//` | +| Source of truth | The OpenAPI spec (which is itself generated from the FastAPI routes) | `endpoints.py` + `types.py`, imported by **both** client and (eventually) server | +| Call style | `sdk.secrets.create(workspace=..., name=..., value=...)` → entity | `client.create_secret(workspace=..., body=CreateSecretRequest(...))` → `NemoResponse`; `.data()` for the entity, `.items()` for a list | +| Types | Generated params/response classes under `nemo_platform.types.secrets.*` | Plain Pydantic models you own | +| Errors | `nemo_platform.NotFoundError`, `ConflictError`, … | `nemo_platform_plugin.client.errors.*` (same names, different module) | +| Regeneration | `make update-sdk` (Stainless round-trip) | none — it's just code | + +The end-goal of AIRCORE-827 is to delete the Stainless dependency once every service has a +NemoClient. Each per-service ticket (873–878) builds one service's typed client; sibling +tickets wire the job factory/provider (AIRCORE-883), first-class auth (AIRCORE-828), +service-to-service URL routing (AIRCORE-854), and server-side route generation (AIRCORE-882). + +### Sequencing: core services first, then plugins + +The migration is phased on purpose: + +1. **Migrate all core services** (Secrets, Files, Jobs, Entities, Models, …) to typed clients. +2. Once every service a plugin talks to has a NemoClient, **plugins no longer need the Stainless + SDK** — their SDK dependency can be dropped. +3. **Plugins then adopt this same system**, defining their own `endpoints.py` / `types.py` / + `client.py` for their surfaces instead of relying on generated code. + +So the core-service work here is the foundation that unblocks the plugin side. + +--- + +## 2. The core infrastructure (already built — you consume it) + +All under `packages/nemo_platform_plugin/src/nemo_platform_plugin/client/` (no `__init__.py`; +import by fully-qualified module path). Documentation is the top-of-file docstrings. + +| File | What it gives you | +| --- | --- | +| `types.py` | Marker types (`BinaryContent`, `Stream[T]`, `Paginated[T, Strategy]`), `PreparedRequest`, `OffsetPagination` (matches the standard `{"data":[...],"pagination":{...}}` envelope), `RetryPolicy`, `ConflictResolver`, `RESERVED_PARAM_NAMES`, `BLESSED_CLIENT_PARAMS` (`exist_ok`). | +| `endpoint.py` | `@get/@post/@put/@patch/@delete` decorators. They read the path template + your signature and build a `PreparedRequest`; **validate params at import time** (unknown param → `TypeError`). | +| `method.py` | `method(endpoint_fn)` descriptor — binds a free endpoint function onto a client class as a real instance method, dispatching sync vs async, with the correct return type per marker. | +| `client.py` | `NemoClient` / `AsyncNemoClient` and `BaseNemoClient`. `send()` resolves the path (fills `workspace` default), injects auth, applies retry, and dispatches by return marker. Also `with_options`, `from_client`, `from_config`. | +| `response.py` | `NemoResponse[T]` (`.data()`), `NemoBinaryResponse` (`.read()`/`.stream()`), `NemoStreamResponse[T]`, `NemoPaginatedResponse[T]` (`.items()`/`.pages()`/`.page()`). | +| `errors.py` | `NemoHTTPError` + status subclasses (`NotFoundError`=404, `ConflictError`=409, `UnprocessableEntityError`=422, …). `raise_for_status()`. | +| `auth.py` / `oidc.py` / `oidc_factory.py` | `TokenProvider` protocols, `StaticToken`, `OIDCTokenProvider`, config-file-backed provider caching. | +| `adapter.py` | `client_from_platform(platform, ClientCls)` — build a NemoClient that **shares** an existing `NeMoPlatform`'s httpx transport (auth/pooling carry over). The migration bridge. | + +### Parameter conventions in `endpoints.py` (enforced at import time) + +Every parameter of an endpoint stub must be one of: + +- a **path placeholder** — a name that appears as `{name}` in the path template; +- `body` — a Pydantic `BaseModel`, serialized to JSON via `model_dump_json(exclude_unset=True)`; +- `content` — raw bytes / (async) iterable of bytes, sent as `application/octet-stream`; +- `query_params` — a `TypedDict` (or dict) of query parameters; +- a **blessed client option** (currently only `exist_ok: bool`), stripped from the wire. + +`workspace` is conventionally `str | None = None` — a path placeholder the client fills from its +default when omitted. Anything else raises `TypeError` when the module is imported. + +### Return markers drive `send()` and `method()` + +| Endpoint return annotation | `send()` returns | Unwrap with | +| --- | --- | --- | +| a Pydantic `BaseModel` | `NemoResponse[T]` | `.data()` → `T` | +| `None` | `NemoResponse[None]` | `.data()` → `None` | +| `Paginated[T]` | `NemoPaginatedResponse[T]` | `.items()` / `.pages()` / `.page()` | +| `BinaryContent` | `NemoBinaryResponse` | `.read()` / `.stream()` | +| `Stream[T]` | `NemoStreamResponse[T]` | `.stream()` | + +--- + +## 3. The per-service template (what you write) + +Mirror `packages/nemo_platform_plugin/src/nemo_platform_plugin/files/`. Create +`packages/nemo_platform_plugin/src/nemo_platform_plugin//` with three files. + +### 3a. `types.py` — request/response models + +Plain Pydantic, owned by the plugin package, and the **single source of truth**: the client +imports these, and the server's `schemas.py` should re-export them (see 3e). Query params as +`TypedDict(total=False)` with `NotRequired`. + +The models live in `nemo_platform_plugin//`, so they inherit that package's dependency +boundary — **don't import `nmp_common`** (or other server-only packages) from `types.py`. The +`files.types` reference deliberately uses `nemo_platform_plugin.schema.Page` instead of +`nmp.common.api.common.Page`. If you need a server constant (e.g. a name-validation regex), inline +it with a comment pointing at the original rather than adding the dependency. + +> **`SecretStr` (and any masked field) will silently send `"**********"`.** +> `model_dump_json()` on a `SecretStr` field emits the mask, not the value — so a request body +> built from it would ship the mask to the server. If a request field must stay `SecretStr` (for +> repr/log masking), add a JSON serializer that emits the real value: +> ```python +> value: SecretStr +> +> @field_serializer("value", when_used="json") +> def _serialize_value(self, value: SecretStr) -> str: +> return value.get_secret_value() +> ``` +> Assert the wire bytes in a test (`test_create_secret_serializes_real_value`) — this is easy to +> miss because the model looks correct. Also note: at call sites that build the request from a plain +> `str`, construct the field explicitly (`CreateXRequest(value=SecretStr(v))`) — `ty` wants +> `SecretStr`, not `str`, and won't coerce. + +### 3b. `endpoints.py` — the HTTP contract + +Free functions, decorated + `@abstractmethod` + `...` body. One function per route. Full path +including the `/apis/` gateway prefix. Use the `get_on_conflict` + `exist_ok` pattern for +create-if-not-exists (see Files' `create_fileset`). + +The decorator's `body` path only serializes a `BaseModel` — there's **no raw-dict body path**. If an +endpoint sends a bare JSON object (`dict[str, Any]`), wrap it in a `RootModel` +(`class XUpdate(RootModel[dict[str, Any]])`) for the client `body`; the server can keep its `dict` +alias. (From the Jobs migration — `update_job_status_details`.) + +### 3c. `client.py` — bind endpoints onto clients + +```python +from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient +from nemo_platform_plugin.client.method import method +from nemo_platform_plugin. import endpoints + +class _ServiceMethods: + create_x = method(endpoints.create_x) + list_x = method(endpoints.list_x) + # ...one line per endpoint... + +class ServiceClient(_ServiceMethods, NemoClient): ... +class AsyncServiceClient(_ServiceMethods, AsyncNemoClient): ... +``` + +### 3d. Tests + +Mirror `packages/nemo_platform_plugin/tests/`: + +- `tests//test_endpoints.py` — assert `PreparedRequest` shape (`.method`, `.path_template`, + `.path_params`, `.content`, `.content_type`, `.response_type` identity, `.query_params`, + workspace-optional behavior). No network. +- `tests/client/`-style — drive `send()` with a `MagicMock(spec=httpx.Client)` / + `AsyncMock(spec=httpx.AsyncClient)` injected as `http_client=`, or use `respx`. + +Also run the service's own SDK integration test (e.g. `test__sdk.py`) after repointing +its fixture (3e) — it drives the real FastAPI routes in-memory and is the best end-to-end check +that path resolution, pagination, error mapping, and body serialization all line up. + +### 3e. Point the server at the shared types (single source of truth) + +Rewrite the service's `api/v2/.../schemas.py` to **re-export** the plugin `types.py` models instead +of redefining them, so client and server can't drift. Keep only server-only concerns (e.g. a +`from_entity` classmethod) as a thin subclass: + +```python +from nemo_platform_plugin..types import ( + CreateXRequest as CreateXRequest, + XResponse as _XResponse, +) + +class XResponse(_XResponse): + @classmethod + def from_entity(cls, entity: X) -> "XResponse": + return cls(name=entity.name, ...) +``` + +This is exactly what `services/core/files/.../filesets/schemas.py` does. The service can import +`nemo_platform_plugin` without adding an explicit dependency (it's available through the workspace), +so no `pyproject.toml` change is needed — but verify the server still imports (`uv run python -c +"import nmp.core..service"`) and that any field validators / `from_entity` behavior survives. + +#### The data-vs-behavior split (when moving types is hard) + +Secrets was easy — its types are pure data. Richer services (Jobs, Models) hit a wall: moving a type +to the plugin drags **runtime deps** (kubernetes, docker) or **server modules** with it, because the +*behavior* lives on the model. The rule: **the plugin holds pure-pydantic wire shapes; the server +subclasses them to re-add behavior.** E.g. `nemo_platform_plugin..types` has +`class KubernetesVolume(BaseModel)` (data only); the server's `common.py` has +`class KubernetesVolume(PluginKubernetesVolume)` with `to_k8s()`. No kubernetes/docker import enters +the plugin. Watch for these (all from the Jobs migration, AIRCORE-874 / PR #585): + +- **Behavior that pulls deps** — methods that build `kubernetes.client.V1*` objects, `to_k8s()`, + validators that import auth/config server modules. These stay on the *server subclass*, not the + plugin model. +- **Import-time env defaults stay server-side.** A field defaulting to `os.getenv(...)` at import + (e.g. a docker network name set by quickstart/e2e) must NOT be inlined as a literal in the plugin — + that silently drops runtime configurability. Plugin gets a plain literal default; the server + subclass restores the env-driven one. +- **Nested field types silently defeat the split.** Subclassing `KubernetesVolume` to add `to_k8s()` + isn't enough — a field `additional_volumes: list[KubernetesVolume]` on some *other* config still + resolves to the plugin (behavior-less) type, so `.to_k8s()` `AttributeError`s at runtime. The server + subclass must **re-type the nested fields too**, cascading all the way up the config tree. Only the + service's own backend tests catch this; the type checker won't. +- **Same-named pydantic classes are NOT interchangeable.** A field typed as the *plugin's* `AuthContext` + rejects a *server* `AuthContext` instance at construction — pydantic treats identically-named classes + as distinct types. And a re-export line (`AuthContext as AuthContext`) in the server schemas module + can silently **shadow** the server import so a subclass field resolves back to the plugin type. Fix: + the server subclass overrides the field with the server type, and you do **not** re-export the plugin + version from the server schemas module. +- **Some types are genuinely server-only — don't move them.** A list *filter* that subclasses + `nmp.common.entities.Filter` (entity-store field translation) or a response that wraps raw entity + instances cannot live in the plugin (`Field 'source' does not exist on model DBEntity`). Leave them + server-side; the client uses query-param strings and DTO equivalents instead. Not everything belongs + in the leaf node. + +--- + +## 4. Migrating the consumers + +Building the client is the easy part. The service's callers are spread across the repo and must +switch off `sdk..*`. + +### The goal: stop *using* Stainless, not force every consumer onto NemoClient + +The real objective is to **remove Stainless SDK usage** for this service so the Stainless dependency +can eventually be dropped — i.e. **no code calls `sdk..*`** (the Stainless subresource) any +more. Concretely that means migrating every real call site to the typed client. It does **not** mean: +- re-plumbing how the SDK is constructed or passed around — the DI seams, factories, and + `Depends(...)` that hand out `NeMoPlatform` keep doing exactly that (swapping those to hand out typed + clients is the separate **AIRCORE-883** effort); nor +- forcing consumers that never touched Stainless onto NemoClient. A raw-HTTP caller + (`subprocess_runtime.py` builds `/apis/secrets/...` URLs itself, no SDK) isn't Stainless usage — it + can stay as-is. Mock-based tests that stub `mock.secrets.access` aren't Stainless *usage* either, + but once the code under test routes through `client_from_platform`, those stubs are dead — remove + them (nothing asserts on them) so no stale Stainless-shaped surface lingers. + +Be exhaustive when inventorying: production **and** test call sites, in `services/*`, `packages/*`, +`plugins/*`, and top-level `tests/*` (e2e/`agentic-use`), plus test-*helpers* and *harnesses*. Tests +that call the real service pass whether or not they're migrated (the service is unchanged), so they +won't show up as CI failures — grep for `.secrets.` across the whole tree, don't rely on red tests. + +The bridge that makes this work at every call site is **`client_from_platform`**: given the +`NeMoPlatform` a consumer already has, it builds a typed client that **shares the same httpx +transport** (so auth headers, connection pooling, base URL all carry over). You adapt-and-call at +the leaf; nothing upstream changes. + +```python +# before — flat kwargs on the Stainless subresource +resp = await sdk.secrets.access(name=name, workspace=workspace) +value = resp.value + +# after — same `sdk`, adapted to the typed client at the call site +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.secrets.client import AsyncSecretsClient + +secrets = client_from_platform(sdk, AsyncSecretsClient) +value = (await secrets.access_secret(name=name, workspace=workspace)).data().value +``` + +A file that uses `sdk.secrets.*` **and** `sdk.models.*` just adapts the secrets calls now and leaves +the `sdk.models.*` calls untouched until Models migrates — no dual-ownership problem, because both +share one underlying transport and the `sdk` is still the thing being passed around. + +**Deeper option for classes you own that hold an SDK to make their own calls:** instead of adapting +inside every method, change the constructor to take the typed client directly — Files did this, +turning `FilesetFileSystem(sdk=...)` into `FilesetFileSystem(client=FilesClient)`, and callers pass +`client_from_platform(sdk, FilesClient)`. Cleaner end-state, but it ripples to every caller, so weigh +it against the leaf-adapter. A middle ground Files also used: keep the old facade +(`sdk.files` / a `FilesResource`) but let it accept an optional `files_client=` and delegate to the +typed client internally — so existing callers keep working while Stainless drops out underneath. + +**Scope discipline: change only what wraps a `sdk..*` call you're replacing.** The `sdk` +construction/plumbing, and every other Stainless import, call, and `except` in the file, stay exactly +as they are. Until the whole codebase is on NemoClient, files legitimately keep importing Stainless +types for their other CRUD — don't "clean those up" in a single-service PR. + +> **Before deferring a consumer, verify it's actually blocked — don't assume a NemoClient gap.** +> A hairy consumer (RBAC tests with per-principal auth, on-behalf-of delegation, multi-service setup) +> can *look* like the client can't handle it. In the Secrets migration the RBAC integration test was +> initially deferred as "needs first-class auth" — that was wrong. Per-principal / on-behalf-of auth +> is just headers (`as_user(sdk, email)` → `with_options(set_default_headers=...)`), and +> `client_from_platform` copies them, so it migrated cleanly. The real coupling was that the test +> *also* drives `sdk.workspaces.*` for setup — which stays Stainless (out of scope), not blocked. +> Lesson: the blocker is almost always test-helper / multi-service coupling, not a client limitation. +> Check what the client can actually do before writing something off. + +At each call site: + +- Replace `sdk..(...)` with `client_from_platform(sdk, ).(...).data()` + (or `.items()` / `.read()` / `.stream()` per the return marker). Adapt once per scope and reuse if + there are several calls. Note the ergonomic shift: the old SDK took flat kwargs + (`sdk.secrets.create(name=..., value=...)`); the typed client takes a request model + (`create_secret(body=CreateXRequest(...))`). +- For the secrets call **you are changing**, catch `nemo_platform_plugin.client.errors.*` instead of + the Stainless exceptions. Subclass names match (`NotFoundError`, `ConflictError`, + `UnprocessableEntityError`, …). **But the error attribute differs:** Stainless used `e.message`; + `NemoHTTPError` uses `e.detail` (plus `e.status_code`, `e.body`). + - **Scope: only the secrets call sites.** Leave every other Stainless import and `except` in the + file untouched — they belong to calls you're *not* migrating. Until the whole codebase is on + NemoClient, files will keep importing Stainless errors for their other CRUD (models, entities, + files, inference, …); that's expected and out of scope for a Secrets PR. Don't repoint a + module-level `NotFoundError` import wholesale, because the file's non-secrets calls still raise + the Stainless one. Instead, add the typed error under a distinct alias and use it *only* in the + `except` for the secrets call. Use the **`Client`-prefixed** convention + (`ClientNotFoundError` / `ClientPermissionDeniedError`) so it's unambiguous which variant it is — + apply the alias even in files whose only error use is the secrets call, for consistency: + ```python + from nemo_platform import NotFoundError # unchanged — still used elsewhere + from nemo_platform_plugin.client.errors import NotFoundError as ClientNotFoundError + ... + try: + await secrets.get_secret(name=name, workspace=ws) + except ClientNotFoundError: ... + ``` + The rule of thumb: touch an import or `except` only when it wraps a `sdk.secrets.*` call you're + replacing. +- Repoint list iteration: the old SDK returned `.data`; the typed client returns a paginated + response — use `.items()` to iterate across pages or `.page().items` for one page. +- **Fix the consumer's tests too — this is the bulk of the work.** Tests that mocked + `sdk.secrets.` break, because the call now goes through `client_from_platform(sdk, )`. + Patch the adapter in the consumer's module so the helper gets a controllable mock client: + ```python + mock_secrets = MagicMock() + mock_secrets.access_secret = AsyncMock(return_value=_resp) # _resp.data().value = "..." + with patch("nmp.core...client_from_platform", return_value=mock_secrets): ... + ``` + Remember the response is wrapped: assert against `mock.access_secret.return_value.data().value`, + and the call is now kwargs (`access_secret(name=..., workspace=...)`), not positional. To simulate + a 404, set `side_effect = NotFoundError(MagicMock(status_code=404))` (a `MagicMock` is a fine + stand-in for the `http_response`). +- Where a test constructs its own `NeMoPlatform(base_url=..., http_client=...)` as a real in-memory + client, build the typed client directly instead (`SecretsClient(base_url=..., http_client=...)`). +- Delete the now-dead Stainless usage (and, once nothing imports it, the Stainless resource). + +### The auto-generated CLI commands + +The `nemo *` command group under `cli/commands/api//` is generated by the CLI generator +(`tools/nemo-platform-sdk-tools/.../cli_generator/`) from the Stainless resource. Two ways a command +can be produced: +- **from the template** — the generator renders `client = state.get_client(); client.. + (**kwargs)`. This is the common case. +- **from an override** — for commands the template can't express (custom stdin handling, special + flags), `cli_config.yaml` points at a hand-written `cli_generator/overrides//.py` that + the generator injects verbatim. + +**Hand-written** CLI code (use-case commands, `setup.py` helpers) migrates like any other consumer: +`client_from_platform(state.get_client(), )`. Do it in the PR. + +**For the generated commands, check `cli_config.yaml` for the service's existing overrides.** How much +you can do in-PR depends on how many commands already have one: +- Editing an **existing** override to call `client_from_platform(sdk, ).(...)` is cheap + — do it in the PR. (Files migrated its CLI this way; it happened to already have an override for every + command it touched.) +- Creating a **net-new** override for a template-generated command, purely to swap the client, is a + poor trade — the override becomes a hand-maintained command body that diverges from the template + forever. Leave those to the generator fix. (Secrets is mostly this case, so its CLI is deferred.) + +The durable answer for template-generated commands is teaching the *template* to emit typed-client +calls — **AIRCORE-893** — so they all migrate at once. (A per-service proxy shim mimicking the Stainless +resource was tried and reverted as the wrong shape; don't repeat it.) + +When either the template or an override moves to the typed client, mind these output-shape points +(Files' overrides show the pattern): +- **List: replace `fetch_all_pages` / `warn_if_more_pages`, don't bridge them.** The typed + `list_()` returns a `NemoPaginatedResponse`; use `ListQueryParams(**kwargs)` + `list(response. + items())` directly (Files dropped the Stainless `fetch_all_pages`/`warn_if_more_pages` helpers + entirely). This is why the earlier "adapt the paginated response to `fetch_all_pages`" shim idea was + wrong — you replace the helper, not adapt to it. (`cli/core/formatters.py::_extract_items_from_ + response` already invokes a `.items()` callable, so list *formatting* just works.) +- **Single-entity commands** (`create`/`get`/`access`/`update`) return a `NemoResponse` → unwrap with + `.data()` before `format_output`. +- **Missing-workspace error** — the CLI keys its friendly exit-2 message off the Stainless + `ValueError("Missing workspace argument")`; the typed client raises `"Missing path parameter + 'workspace' ..."`. If a migrated command can hit this, translate the message. + +--- + +## 5. Worked example: Secrets (AIRCORE-873) + +### Contract — 7 endpoints, 2 routers, served under `/apis/secrets` + +Router mounting adds no prefix (`RouterConfig.prefix=""`); the `/apis/secrets` prefix is applied +by the gateway (`/apis/{service.name}`). In-app paths start at `/v2/...`. + +**`secrets/` router → `SecretsClient` methods** + +| Method | HTTP | Path (full) | Body | Query | Returns | Status | +| --- | --- | --- | --- | --- | --- | --- | +| `create_secret` | POST | `/apis/secrets/v2/workspaces/{workspace}/secrets` | `PlatformSecretCreateRequest` | — | `PlatformSecretResponse` | 201 | +| `list_secrets` | GET | `/apis/secrets/v2/workspaces/{workspace}/secrets` | — | `page`, `page_size` | `Paginated[PlatformSecretResponse]` | 200 | +| `get_secret` | GET | `/apis/secrets/v2/workspaces/{workspace}/secrets/{name}` | — | — | `PlatformSecretResponse` | 200 | +| `update_secret` | PATCH | `/apis/secrets/v2/workspaces/{workspace}/secrets/{name}` | `PlatformSecretUpdateRequest` | — | `PlatformSecretResponse` | 200 | +| `delete_secret` | DELETE | `/apis/secrets/v2/workspaces/{workspace}/secrets/{name}` | — | — | `None` | 204 | +| `access_secret` | GET | `/apis/secrets/v2/workspaces/{workspace}/secrets/{name}/access` | — | — | `PlatformSecretAccessResponse` | 200 | + +**`admin/` router → `SecretsClient.admin` (or flat `rotate_encryption_keys`)** + +| Method | HTTP | Path (full) | Body | Returns | Status | +| --- | --- | --- | --- | --- | --- | +| `rotate_encryption_keys` | POST | `/apis/secrets/v2/rotate-encryption-keys` | — | `PlatformSecretAdminRotationResponse` | 202 | + +The models now live in `nemo_platform_plugin/secrets/types.py` as the single source of truth +(`PlatformSecretResponse`, `PlatformSecretCreateRequest` — `name` regex-validated inline, +`value: SecretStr` with a JSON serializer, non-empty — `PlatformSecretUpdateRequest`, +`PlatformSecretAccessResponse`, `PlatformSecretAdminRotationResponse`). The server's +`api/v2/secrets/schemas.py` re-exports them and subclasses `PlatformSecretResponse` only to keep +`from_entity`. See 3a/3e. + +### Watch-outs specific to Secrets + +- **Admin auth differs from CRUD.** The `secrets/` CRUD routes carry **no in-handler auth** — + authorization is gateway-enforced via `static-authz.yaml` (perms `secrets.create/list/read/ + update/delete/access`, scopes `platform:*`/`secrets:*`). The `admin/` route enforces + `secrets.rotate` **in code**, in the `system` workspace. The client itself carries auth headers + the same way for both; just make sure the `admin` endpoint's path/scoping is preserved and + covered by a test. +- **`access_secret` has an extra on-behalf-of check** (403 if the delegated principal lacks + `secrets.read`). Behavior lives server-side; the client just needs to surface 403 as + `PermissionDeniedError`. +- **Pagination, 2 refs.** (1) `list_secrets` — `page`/`page_size`, returns the standard `Page` + envelope, so `Paginated[PlatformSecretResponse]` with the default `OffsetPagination` works + as-is. (2) The admin rotation routine paginates internally server-side — **not** client-facing. +- **Workspace validation → 422.** Creating in a non-existent workspace surfaces as + `UnprocessableEntityError` (message `"Workspace '{workspace}' does not exist"`) from the entity + store, not from secrets code. Model 422 on create in tests. +- **Default NGC API key** is synthetically injected/guarded server-side (403 on + create/update/delete of it; synthetic value on get/list/access). No client change; just surface + 403 as `PermissionDeniedError`. +- **`value` is a `SecretStr` → confirmed serialization gotcha.** Without a JSON serializer the + request would ship `"**********"`. Fixed in `types.py` with `@field_serializer(..., when_used= + "json")` returning `get_secret_value()`; covered by `test_create_secret_serializes_real_value`. + See the boxed warning in 3a. + +### How the general steps played out for Secrets + +- **Contract** was small and pure-data, so §3a–3e went straight through: the models moved to + `nemo_platform_plugin/secrets/types.py` and the server `schemas.py` re-exports them, subclassing only + to keep `from_entity`. No data-vs-behavior split needed. +- **Consumers** ranged across the shapes §4 describes: production s2s callers (files/jobs/models/ + inference-gateway/data_designer) and hand-written CLI helpers via `client_from_platform` at the leaf; + the RBAC integration suite via `client_from_platform(as_user(sdk, email), SecretsClient)` per + principal (confirming per-principal/on-behalf-of auth is just headers the adapter carries); and a long + tail of test setup/harness/e2e calls. Stale `mock.secrets.*` stubs were removed once the code routed + through the adapter. +- **Left out** (per "stop using Stainless," not "force NemoClient on everything"): the generated + `nemo secrets` CLI (mostly template-generated commands → AIRCORE-893, §4), a raw-HTTP caller that + never used the SDK, and a few docstring examples. + +The concrete per-file inventory lives in the PR description, not here — this doc is the reusable +playbook. + +--- + +## 6. Generalized checklist (copy per service) + +- [ ] **Scope the contract.** List every route (method, full `/apis/` path, path/query/body, + response model, status, auth). Note pagination, binary/stream endpoints, privileged routers. +- [ ] **Write `types.py`** (models + `TypedDict` query params) as the source of truth. Watch masked + fields (`SecretStr`) — add a JSON `field_serializer`. No `nmp_common` imports. +- [ ] **Write `endpoints.py`** (decorated `@abstractmethod` stubs; `get_on_conflict`/`exist_ok` + where create-if-not-exists is wanted). +- [ ] **Write `client.py`** (`_Methods` mixin + sync/async subclasses). +- [ ] **Client tests** (`test_endpoints.py` shape asserts + `send()` with mocked httpx; assert a + masked field serializes to its real value). +- [ ] **Point the server `schemas.py` at the shared types** (re-export; subclass only for + `from_entity`). Verify the server still imports. +- [ ] **Inventory consumers** across `services/*`, `plugins/*`, `packages/*`, `tools/*`, `tests/*`, + the CLI, and any raw `/apis/` HTTP. +- [ ] **Migrate every `sdk..*` call site** via `client_from_platform(sdk, )` at the leaf + (`client.(...).data()`, catch `client.errors.*` — note `.detail` not `.message`, + `.items()` not `.data`). The adapter handles the DI-injected consumers too, so almost nothing is + genuinely blocked — see §4. If you *do* leave something out (raw-HTTP callers, the generated CLI), + **list it explicitly** in the PR description; don't silently skip it. +- [ ] **Update the service's own test fixtures** to hand out the new client instead of + `NeMoPlatform(...)`. Check for *local* fixtures in individual test files too (they override + conftest). Mocked-secrets tests need re-wiring (patch `client_from_platform`, `.data()`-wrap). +- [ ] **If you touched `nemo_platform_ext`** (e.g. `setup.py` helpers, a use-case command), run + `make vendor-nemo-platform-ext` — that package is vendored into `sdk/python/nemo-platform`, and + the CLI runs from the vendored copy. Vendored `sdk/python` is ruff-excluded, so don't chase its + pre-existing lint errors. +- [ ] **Preserve auth semantics** — privileged routers, on-behalf-of checks, scopes. +- [ ] **Run** `uv run --frozen ty check`, `uv run ruff check`, `make test-package PACKAGE=nemo_platform_plugin`, + `make test-unit-`, and the service's integration tests. **For `ty`, measure against a + baseline, not zero** — services carry many pre-existing diagnostics (Jobs had ~115). Stash your + change, capture the diagnostics, then diff categories (line numbers shift, so location diffs are + noise). Goal: **0 net-new** errors. +- [ ] **Run the service's own backend/integration tests**, not just unit — the data-vs-behavior split + (3e) fails in ways the type checker misses (a nested field resolving to the behavior-less plugin + type only `AttributeError`s at runtime, caught by the k8s/docker backend tests). +- [ ] **Drop the Stainless resource** for this service once nothing imports it, and feed the pattern + into AIRCORE-882 (server route gen). AIRCORE-883 (job factory hands out typed clients) is a + *separate* cleanup — it removes the per-call-site adapter, it does **not** gate this migration. + +--- + +## 7. Known gaps / cross-cutting tickets + +- **AIRCORE-828** — first-class auth (standalone, not via the `client_from_platform` adapter). +- **AIRCORE-854** — service-to-service URL routing for NemoClient. +- **AIRCORE-882** — generate FastAPI routes from `endpoints.py` (the shared-contract payoff). +- **AIRCORE-883** — job client factory/provider hands out NemoClient instead of `NeMoPlatform`. This + does **not** gate the per-service migrations — the `client_from_platform` adapter lets consumers + migrate today with the `NeMoPlatform` they already have. 883 is the follow-up cleanup that removes + those per-call-site adapters once the factory hands out typed clients directly. +- **AIRCORE-840** — remove the Files Stainless-error-remapping shim. Files was migrated with a + temporary compat shim; later services skip the shim and migrate consumers directly instead. +- **AIRCORE-893** — migrate the CLI generator to typed clients. The `nemo *` command group is + generated by introspecting the Stainless SDK, so it's the last hard Stainless consumer and blocks + removing the Stainless dependency. The preferred direction is one generic `sdk.nemo_client` seam, + not per-service shims (a per-service shim was prototyped during Secrets and reverted as the wrong + shape). See §4. diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/secrets/client.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/secrets/client.py new file mode 100644 index 0000000000..d7d04556a8 --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/secrets/client.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed HTTP clients for the Secrets service. + +Wraps the endpoint functions from ``secrets.endpoints`` as direct methods +using the ``method()`` descriptor, following the Files client pattern. + +Usage:: + + client = SecretsClient(base_url="...", workspace="default") + secret = client.create_secret(body=PlatformSecretCreateRequest(name="hf-token", value="...")).data() + value = client.access_secret(name="hf-token").data().value +""" + +from nemo_platform_plugin.client.client import AsyncNemoClient, NemoClient +from nemo_platform_plugin.client.method import method +from nemo_platform_plugin.secrets import endpoints + + +class _SecretsMethods: + create_secret = method(endpoints.create_secret) + list_secrets = method(endpoints.list_secrets) + get_secret = method(endpoints.get_secret) + update_secret = method(endpoints.update_secret) + delete_secret = method(endpoints.delete_secret) + access_secret = method(endpoints.access_secret) + rotate_encryption_keys = method(endpoints.rotate_encryption_keys) + + +class SecretsClient(_SecretsMethods, NemoClient): + """Sync client for the Secrets service API.""" + + +class AsyncSecretsClient(_SecretsMethods, AsyncNemoClient): + """Async client for the Secrets service API.""" diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/secrets/endpoints.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/secrets/endpoints.py new file mode 100644 index 0000000000..189caab190 --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/secrets/endpoints.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed endpoint definitions for the Secrets service. + +These are the single source of truth for the HTTP contract. Paths include the +``/apis/secrets`` gateway prefix. + +The service exposes two routers: +- the ``secrets`` CRUD + access router (workspace-scoped), and +- the privileged ``admin`` router (``rotate-encryption-keys``), which the server + gates on the ``secrets.rotate`` permission in the ``system`` workspace. +""" + +from __future__ import annotations + +from abc import abstractmethod + +from nemo_platform_plugin.client.endpoint import delete, get, patch, post +from nemo_platform_plugin.client.types import Paginated, PreparedRequest +from nemo_platform_plugin.secrets.types import ( + ListSecretsQueryParams, + PlatformSecretAccessResponse, + PlatformSecretAdminRotationResponse, + PlatformSecretCreateRequest, + PlatformSecretResponse, + PlatformSecretUpdateRequest, +) + +# --------------------------------------------------------------------------- +# Secret CRUD +# --------------------------------------------------------------------------- + + +@get("/apis/secrets/v2/workspaces/{workspace}/secrets/{name}") +@abstractmethod +def get_secret(*, workspace: str | None = None, name: str) -> PlatformSecretResponse: ... + + +@get("/apis/secrets/v2/workspaces/{workspace}/secrets") +@abstractmethod +def list_secrets( + *, workspace: str | None = None, query_params: ListSecretsQueryParams | None = None +) -> Paginated[PlatformSecretResponse]: ... + + +def _get_secret_on_conflict( + body: PlatformSecretCreateRequest, workspace: str | None +) -> PreparedRequest[PlatformSecretResponse]: + """Build the retrieve request replayed when ``create_secret(exist_ok=True)`` 409s.""" + return get_secret(name=body.name, workspace=workspace) + + +@post("/apis/secrets/v2/workspaces/{workspace}/secrets", get_on_conflict=_get_secret_on_conflict) +@abstractmethod +def create_secret( + *, workspace: str | None = None, body: PlatformSecretCreateRequest, exist_ok: bool = False +) -> PlatformSecretResponse: ... + + +@patch("/apis/secrets/v2/workspaces/{workspace}/secrets/{name}") +@abstractmethod +def update_secret( + *, workspace: str | None = None, name: str, body: PlatformSecretUpdateRequest +) -> PlatformSecretResponse: ... + + +@delete("/apis/secrets/v2/workspaces/{workspace}/secrets/{name}") +@abstractmethod +def delete_secret(*, workspace: str | None = None, name: str) -> None: ... + + +# --------------------------------------------------------------------------- +# Secret value access +# --------------------------------------------------------------------------- + + +@get("/apis/secrets/v2/workspaces/{workspace}/secrets/{name}/access") +@abstractmethod +def access_secret(*, workspace: str | None = None, name: str) -> PlatformSecretAccessResponse: ... + + +# --------------------------------------------------------------------------- +# Admin +# --------------------------------------------------------------------------- + + +@post("/apis/secrets/v2/rotate-encryption-keys") +@abstractmethod +def rotate_encryption_keys() -> PlatformSecretAdminRotationResponse: ... diff --git a/packages/nemo_platform_plugin/src/nemo_platform_plugin/secrets/types.py b/packages/nemo_platform_plugin/src/nemo_platform_plugin/secrets/types.py new file mode 100644 index 0000000000..b52581a149 --- /dev/null +++ b/packages/nemo_platform_plugin/src/nemo_platform_plugin/secrets/types.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared request/response types for the Secrets service. + +These types define the HTTP contract for secret CRUD, value access, and the +admin key-rotation endpoint. Both the server (FastAPI routes) and the client +(NemoClient endpoints) import from here — one source of truth, no +Stainless-generated duplicates. +""" + +from __future__ import annotations + +import re +from datetime import datetime +from typing import NotRequired, Self, TypedDict + +from pydantic import BaseModel, Field, SecretStr, field_serializer, field_validator, model_validator + +# Mirrors ``nmp.common.entities.constants.REGEX_WORD_CHARACTER_DOT_DASH``. Inlined +# rather than imported so this package stays free of an ``nmp_common`` dependency +# (matching the ``files.types`` boundary — plugin types own their own contract). +_NAME_REGEX = r"^[\w\-.]+$" +_NAME_RE: re.Pattern[str] = re.compile(_NAME_REGEX) + +# --------------------------------------------------------------------------- +# Response types +# --------------------------------------------------------------------------- + + +class PlatformSecretResponse(BaseModel): + """Response model for a platform secret.""" + + name: str = Field(description="The name of the secret") + workspace: str = Field(description="The workspace ID the secret belongs to") + description: str | None = Field(default=None, description="An optional description of the secret") + created_at: datetime | None = Field(default=None) + updated_at: datetime | None = Field(default=None) + + +class PlatformSecretAccessResponse(BaseModel): + """Response model for accessing a platform secret's value.""" + + name: str = Field(description="The name of the secret") + workspace: str = Field(description="The workspace ID the secret belongs to") + value: str = Field(description="The payload of the secret") + + +class PlatformSecretAdminRotationResponse(BaseModel): + """Response DTO for the admin key-rotation routine.""" + + rotated_secrets: int + success: bool + + +# --------------------------------------------------------------------------- +# Request types +# --------------------------------------------------------------------------- + + +class PlatformSecretCreateRequest(BaseModel): + """Request body for creating a new platform secret.""" + + # ``value`` is a ``SecretStr`` so it is masked in reprs/logs, but the + # ``_serialize_value`` JSON serializer below emits the real plaintext on the + # wire — without it, ``model_dump_json`` would send ``"**********"`` and the + # server would store the mask instead of the secret. (Keep this as a comment, + # not the class docstring, so it does not leak into the OpenAPI schema.) + + name: str = Field( + description=( + "The name of the secret to create. Allowed characters: letters (a-z, A-Z), " + "digits (0-9), underscores, hyphens, and dots." + ), + examples=["hf-token", "wandb-api-key"], + ) + description: str | None = Field(default=None, description="An optional description of the secret") + value: SecretStr = Field(description="The payload of the secret") + + @field_validator("name") + @classmethod + def _validate_name(cls, v: str) -> str: + if not _NAME_RE.match(v): + raise ValueError( + f"Invalid secret name '{v}'. Allowed characters: letters, digits, underscores, " + "hyphens, and dots. Example: my-api-key" + ) + return v + + @field_serializer("value", when_used="json") + def _serialize_value(self, value: SecretStr) -> str: + return value.get_secret_value() + + @model_validator(mode="after") + def _validate_value(self) -> Self: + if not self.value.get_secret_value(): + raise ValueError("Secret value cannot be empty") + return self + + +class PlatformSecretUpdateRequest(BaseModel): + """Request body for updating a platform secret's metadata.""" + + description: str | None = Field(default=None, description="An optional description of the secret") + value: SecretStr | None = Field(default=None, description="The new secret value") + + @field_serializer("value", when_used="json") + def _serialize_value(self, value: SecretStr | None) -> str | None: + return value.get_secret_value() if value is not None else None + + @model_validator(mode="after") + def _validate_value(self) -> Self: + if self.value is not None and not self.value.get_secret_value(): + raise ValueError("Secret value cannot be empty") + return self + + +# --------------------------------------------------------------------------- +# Query parameter types +# --------------------------------------------------------------------------- + + +class ListSecretsQueryParams(TypedDict, total=False): + page: NotRequired[int] + page_size: NotRequired[int] diff --git a/packages/nemo_platform_plugin/tests/secrets/test_client.py b/packages/nemo_platform_plugin/tests/secrets/test_client.py new file mode 100644 index 0000000000..bb3666616b --- /dev/null +++ b/packages/nemo_platform_plugin/tests/secrets/test_client.py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the SecretsClient / AsyncSecretsClient via mocked httpx transport.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from nemo_platform_plugin.client.errors import NotFoundError +from nemo_platform_plugin.secrets.client import AsyncSecretsClient, SecretsClient +from nemo_platform_plugin.secrets.types import ( + PlatformSecretCreateRequest, + PlatformSecretUpdateRequest, +) + +BASE = "http://test:8000" + + +def test_create_secret_sends_plaintext_value() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 201, + request=httpx.Request("POST", f"{BASE}/apis/secrets/v2/workspaces/default/secrets"), + json={"name": "hf-token", "workspace": "default", "description": None}, + ) + + client = SecretsClient(base_url=BASE, workspace="default", http_client=mock_http) + resp = client.create_secret(body=PlatformSecretCreateRequest(name="hf-token", value="nvapi-xyz")) + + assert resp.data().name == "hf-token" + _, kwargs = mock_http.request.call_args + assert b'"value":"nvapi-xyz"' in kwargs["content"] + + +def test_access_secret_returns_value() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 200, + request=httpx.Request("GET", f"{BASE}/apis/secrets/v2/workspaces/default/secrets/hf-token/access"), + json={"name": "hf-token", "workspace": "default", "value": "nvapi-xyz"}, + ) + + client = SecretsClient(base_url=BASE, workspace="default", http_client=mock_http) + resp = client.access_secret(name="hf-token") + + assert resp.data().value == "nvapi-xyz" + + +def test_get_secret_not_found_raises() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 404, + request=httpx.Request("GET", f"{BASE}/apis/secrets/v2/workspaces/default/secrets/missing"), + json={"detail": "Secret default/missing not found"}, + ) + + client = SecretsClient(base_url=BASE, workspace="default", http_client=mock_http) + with pytest.raises(NotFoundError) as exc: + client.get_secret(name="missing") + assert exc.value.status_code == 404 + + +def test_update_secret_sends_plaintext_value() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 200, + request=httpx.Request("PATCH", f"{BASE}/apis/secrets/v2/workspaces/default/secrets/hf-token"), + json={"name": "hf-token", "workspace": "default", "description": "d"}, + ) + + client = SecretsClient(base_url=BASE, workspace="default", http_client=mock_http) + client.update_secret(name="hf-token", body=PlatformSecretUpdateRequest(value="newvalue")) + + _, kwargs = mock_http.request.call_args + assert b'"value":"newvalue"' in kwargs["content"] + + +def test_rotate_encryption_keys() -> None: + mock_http = MagicMock(spec=httpx.Client) + mock_http.request.return_value = httpx.Response( + 202, + request=httpx.Request("POST", f"{BASE}/apis/secrets/v2/rotate-encryption-keys"), + json={"rotated_secrets": 0, "success": True}, + ) + + client = SecretsClient(base_url=BASE, http_client=mock_http) + resp = client.rotate_encryption_keys() + + assert resp.data().success is True + + +@pytest.mark.asyncio +async def test_async_create_secret() -> None: + mock_http = AsyncMock(spec=httpx.AsyncClient) + mock_http.request.return_value = httpx.Response( + 201, + request=httpx.Request("POST", f"{BASE}/apis/secrets/v2/workspaces/default/secrets"), + json={"name": "hf-token", "workspace": "default", "description": None}, + ) + + client = AsyncSecretsClient(base_url=BASE, workspace="default", http_client=mock_http) + resp = await client.create_secret(body=PlatformSecretCreateRequest(name="hf-token", value="nvapi-xyz")) + + assert resp.data().name == "hf-token" diff --git a/packages/nemo_platform_plugin/tests/secrets/test_endpoints.py b/packages/nemo_platform_plugin/tests/secrets/test_endpoints.py new file mode 100644 index 0000000000..771c33cfbf --- /dev/null +++ b/packages/nemo_platform_plugin/tests/secrets/test_endpoints.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Secrets service endpoint definitions.""" + +from __future__ import annotations + +import json +from typing import get_origin + +from nemo_platform_plugin.client.types import Paginated, PreparedRequest +from nemo_platform_plugin.secrets import endpoints +from nemo_platform_plugin.secrets.types import ( + PlatformSecretAccessResponse, + PlatformSecretAdminRotationResponse, + PlatformSecretCreateRequest, + PlatformSecretResponse, + PlatformSecretUpdateRequest, +) + + +def test_create_secret() -> None: + body = PlatformSecretCreateRequest(name="hf-token", value="nvapi-xyz") + prepared = endpoints.create_secret(workspace="default", body=body) + + assert isinstance(prepared, PreparedRequest) + assert prepared.method == "POST" + assert prepared.path_template == "/apis/secrets/v2/workspaces/{workspace}/secrets" + assert prepared.path_params == {"workspace": "default"} + assert prepared.content_type == "application/json" + assert prepared.response_type is PlatformSecretResponse + + +def test_create_secret_serializes_real_value() -> None: + """Regression: SecretStr must serialize the plaintext, not the '****' mask.""" + body = PlatformSecretCreateRequest(name="hf-token", value="nvapi-xyz") + prepared = endpoints.create_secret(workspace="default", body=body) + + content = json.loads(prepared.content) + assert content == {"name": "hf-token", "value": "nvapi-xyz"} + + +def test_create_secret_workspace_optional() -> None: + body = PlatformSecretCreateRequest(name="hf-token", value="v") + prepared = endpoints.create_secret(body=body) + + assert prepared.path_params == {} + + +def test_list_secrets() -> None: + prepared = endpoints.list_secrets(workspace="default") + + assert prepared.method == "GET" + assert prepared.path_template == "/apis/secrets/v2/workspaces/{workspace}/secrets" + assert prepared.path_params == {"workspace": "default"} + assert prepared.content is None + assert get_origin(prepared.response_type) is Paginated + + +def test_list_secrets_with_query_params() -> None: + prepared = endpoints.list_secrets(workspace="default", query_params={"page": 2, "page_size": 5}) + + assert prepared.query_params == {"page": 2, "page_size": 5} + + +def test_get_secret() -> None: + prepared = endpoints.get_secret(workspace="default", name="hf-token") + + assert prepared.method == "GET" + assert prepared.path_params == {"workspace": "default", "name": "hf-token"} + assert prepared.response_type is PlatformSecretResponse + + +def test_update_secret() -> None: + body = PlatformSecretUpdateRequest(description="updated") + prepared = endpoints.update_secret(workspace="default", name="hf-token", body=body) + + assert prepared.method == "PATCH" + assert prepared.path_params == {"workspace": "default", "name": "hf-token"} + assert prepared.response_type is PlatformSecretResponse + + +def test_update_secret_excludes_unset_and_serializes_value() -> None: + body = PlatformSecretUpdateRequest(value="newvalue") + prepared = endpoints.update_secret(workspace="default", name="hf-token", body=body) + + content = json.loads(prepared.content) + assert content == {"value": "newvalue"} + assert "description" not in content + + +def test_delete_secret_returns_none() -> None: + prepared = endpoints.delete_secret(workspace="default", name="hf-token") + + assert prepared.method == "DELETE" + assert prepared.path_params == {"workspace": "default", "name": "hf-token"} + assert prepared.content is None + assert prepared.response_type is None + + +def test_access_secret() -> None: + prepared = endpoints.access_secret(workspace="default", name="hf-token") + + assert prepared.method == "GET" + assert prepared.path_template == "/apis/secrets/v2/workspaces/{workspace}/secrets/{name}/access" + assert prepared.path_params == {"workspace": "default", "name": "hf-token"} + assert prepared.response_type is PlatformSecretAccessResponse + + +def test_rotate_encryption_keys() -> None: + prepared = endpoints.rotate_encryption_keys() + + assert prepared.method == "POST" + assert prepared.path_template == "/apis/secrets/v2/rotate-encryption-keys" + assert prepared.path_params == {} + assert prepared.content is None + assert prepared.response_type is PlatformSecretAdminRotationResponse diff --git a/plugins/nemo-data-designer/src/nemo_data_designer_plugin/cli/personas.py b/plugins/nemo-data-designer/src/nemo_data_designer_plugin/cli/personas.py index 8f8d7e283c..50c45ccc46 100644 --- a/plugins/nemo-data-designer/src/nemo_data_designer_plugin/cli/personas.py +++ b/plugins/nemo-data-designer/src/nemo_data_designer_plugin/cli/personas.py @@ -17,7 +17,12 @@ get_resource_name_for_locale, sync_nemotron_personas_fileset, ) -from nemo_platform import ConflictError, NeMoPlatform +from nemo_platform import NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.client.errors import ConflictError +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest +from pydantic import SecretStr _SUPPORTED_LOCALE_NAMES = sorted(SUPPORTED_LOCALES) @@ -134,7 +139,11 @@ def make_fileset_command( if api_key is not None: try: - sdk.secrets.create(workspace=secret_workspace, name=secret_name, value=api_key) + secrets = client_from_platform(sdk, SecretsClient) + secrets.create_secret( + workspace=secret_workspace, + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(api_key)), + ) except ConflictError as exc: print_error( f"Secret {api_key_secret!r} already exists. Omit --api-key-env-var to reuse an existing secret." diff --git a/plugins/nemo-data-designer/src/nemo_data_designer_plugin/testing/utils.py b/plugins/nemo-data-designer/src/nemo_data_designer_plugin/testing/utils.py index 8ccf60597d..9c613efbb3 100644 --- a/plugins/nemo-data-designer/src/nemo_data_designer_plugin/testing/utils.py +++ b/plugins/nemo-data-designer/src/nemo_data_designer_plugin/testing/utils.py @@ -37,6 +37,8 @@ from nemo_platform_plugin.job_results import PlatformJobResults from nemo_platform_plugin.jobs.api_factory import PlatformJobSpec from nemo_platform_plugin.jobs.result_manager import ResultManager +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from nmp.core.files.service import FilesService from nmp.core.inference_gateway.service import InferenceGatewayService from nmp.core.jobs.service import JobsService @@ -44,6 +46,7 @@ from nmp.core.secrets.service import SecretsService from nmp.platform_runner.plugin_adapter import NemoServiceAdapter from nmp.testing import ClientContext, TaskResult, add_mock_provider, create_test_client, subprocess_job_executor_patch +from pydantic import SecretStr WORKSPACE_NAME = "my-workspace" @@ -169,9 +172,9 @@ def setup_mock_providers(client_context: ClientContext) -> Generator[None]: @contextmanager def setup_mock_secret(client_context: ClientContext) -> Generator[None]: - client_context.sdk.secrets.create( - value=SECRET_RAW_VALUE, - name=SECRET_NAME, + secrets = client_from_platform(client_context.sdk, SecretsClient) + secrets.create_secret( + body=PlatformSecretCreateRequest(name=SECRET_NAME, value=SecretStr(SECRET_RAW_VALUE)), workspace=client_context.sdk.workspace or WORKSPACE_NAME, ) yield diff --git a/plugins/nemo-data-designer/tests/integration/test_personas_cli.py b/plugins/nemo-data-designer/tests/integration/test_personas_cli.py index c13ac9efaf..fcc9cbae00 100644 --- a/plugins/nemo-data-designer/tests/integration/test_personas_cli.py +++ b/plugins/nemo-data-designer/tests/integration/test_personas_cli.py @@ -12,6 +12,9 @@ from nemo_platform_plugin.client.adapter import client_from_platform from nemo_platform_plugin.files.client import FilesClient from nemo_platform_plugin.files.storage_config import NGCStorageConfig +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest +from pydantic import SecretStr pytestmark = pytest.mark.integration @@ -105,7 +108,7 @@ def test_make_fileset_creates_secret_from_env_then_fileset( ) assert result.exit_code == 0, result.output - secret = cli_sdk.secrets.access("my-ngc-key", workspace="system") + secret = client_from_platform(cli_sdk, SecretsClient).access_secret(name="my-ngc-key", workspace="system").data() assert secret.value == "nvapi-from-env" files = client_from_platform(cli_sdk, FilesClient) @@ -169,7 +172,10 @@ def test_make_fileset_bare_secret_name() -> None: def test_make_fileset_create_secret_conflict_does_not_create_fileset( monkeypatch: pytest.MonkeyPatch, cli_sdk: NeMoPlatform ) -> None: - cli_sdk.secrets.create(workspace="system", name="my-ngc-key", value="nvapi-existing") + client_from_platform(cli_sdk, SecretsClient).create_secret( + workspace="system", + body=PlatformSecretCreateRequest(name="my-ngc-key", value=SecretStr("nvapi-existing")), + ) monkeypatch.setenv("MY_NGC_API_KEY", "nvapi-from-env") result = u.invoke_cli( @@ -199,7 +205,12 @@ def test_make_fileset_create_secret_internal_error_surfaces_clearly( def _boom(*args: object, **kwargs: object) -> None: raise RuntimeError("secrets backend exploded") - with patch.object(cli_sdk.secrets, "create", side_effect=_boom): + # The CLI creates secrets via ``client_from_platform(sdk, SecretsClient).create_secret``. + # ``create_secret`` is a descriptor whose ``__get__`` rejects class-level access, so it + # can't be patched on the class; intercept at the ``client_from_platform`` boundary instead. + mock_secrets = Mock() + mock_secrets.create_secret.side_effect = _boom + with patch.object(personas_module, "client_from_platform", return_value=mock_secrets): result = u.invoke_cli( [ "personas", diff --git a/plugins/nemo-evaluator/examples/plugin_examples.py b/plugins/nemo-evaluator/examples/plugin_examples.py index adb6e067b5..982440d764 100644 --- a/plugins/nemo-evaluator/examples/plugin_examples.py +++ b/plugins/nemo-evaluator/examples/plugin_examples.py @@ -39,12 +39,17 @@ SecretRef, ) from nemo_evaluator_sdk.values.results import EvaluationResult -from nemo_platform import APIError, AsyncNeMoPlatform, ConflictError, NeMoPlatform, NotFoundError +from nemo_platform import APIError, AsyncNeMoPlatform, NeMoPlatform from nemo_platform_plugin.client import errors as files_errors from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.client.errors import ConflictError as ClientConflictError +from nemo_platform_plugin.client.errors import NotFoundError as ClientNotFoundError from nemo_platform_plugin.files.client import AsyncFilesClient, FilesClient from nemo_platform_plugin.files.storage_config import HuggingfaceStorageConfig from nemo_platform_plugin.files.types import CreateFilesetRequest +from nemo_platform_plugin.secrets.client import AsyncSecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest +from pydantic import SecretStr if TYPE_CHECKING: import numpy as np @@ -250,9 +255,10 @@ def ensure_example_fileset_sync(client: NeMoPlatform) -> FilesetRef: async def ensure_submit_evaluator_api_key_secret(workspace: str, client: AsyncNeMoPlatform) -> str: """Resolve an API key secret name and ensure it exists on the platform.""" secret_name = DEFAULT_API_KEY_SECRET.lower().replace("_", "-") + secrets = client_from_platform(client, AsyncSecretsClient) try: - await client.secrets.retrieve(secret_name, workspace=workspace) - except NotFoundError: + await secrets.get_secret(name=secret_name, workspace=workspace) + except ClientNotFoundError: api_key = os.getenv(DEFAULT_API_KEY_SECRET) or os.getenv("NVIDIA_API_KEY") or os.getenv("NVIDIA_BUILD_API_KEY") if api_key is None: raise RuntimeError( @@ -262,9 +268,12 @@ async def ensure_submit_evaluator_api_key_secret(workspace: str, client: AsyncNe f"nemo secrets create {secret_name} --value '' --workspace {workspace}" ) from None try: - await client.secrets.create(workspace=workspace, name=secret_name, value=api_key) + await secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(api_key)), + workspace=workspace, + ) print("API key secret created for workspace") - except ConflictError: + except ClientConflictError: pass return secret_name diff --git a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/setup.py b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/setup.py index eddb64d742..03943305c9 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/cli/commands/setup.py +++ b/sdk/python/nemo-platform/src/nemo_platform/cli/commands/setup.py @@ -26,7 +26,11 @@ import typer import yaml as _yaml from nemo_platform import NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest, PlatformSecretUpdateRequest from nmp.common.config import nmp_user_data_dir +from pydantic import SecretStr from rich import box from rich.console import Console from rich.panel import Panel @@ -385,19 +389,22 @@ def _provider_exists(client: NeMoPlatform, name: str, workspace: str) -> bool: def _secret_exists(client: NeMoPlatform, name: str, workspace: str) -> bool: """Return True if a secret with *name* already exists.""" + secrets = client_from_platform(client, SecretsClient) try: - client.secrets.retrieve(name, workspace=workspace) + secrets.get_secret(name=name, workspace=workspace) return True except Exception: return False def _create_secret(client: NeMoPlatform, name: str, value: str, workspace: str) -> None: - client.secrets.create(name=name, value=value, workspace=workspace) + secrets = client_from_platform(client, SecretsClient) + secrets.create_secret(body=PlatformSecretCreateRequest(name=name, value=SecretStr(value)), workspace=workspace) def _update_secret(client: NeMoPlatform, name: str, value: str, workspace: str) -> None: - client.secrets.update(name, value=value, workspace=workspace) + secrets = client_from_platform(client, SecretsClient) + secrets.update_secret(name=name, body=PlatformSecretUpdateRequest(value=SecretStr(value)), workspace=workspace) def _create_provider( diff --git a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup.py b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup.py index 4904dc6962..0998f9e6f5 100644 --- a/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup.py +++ b/sdk/python/nemo-platform/tests/vendored/nemo_platform_ext/cli/commands/test_setup.py @@ -77,6 +77,8 @@ Context, ContextDefinition, ) +from nemo_platform_plugin.client.errors import NotFoundError +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest, PlatformSecretUpdateRequest SETUP_MOD = "nemo_platform.cli.commands.setup" @@ -241,14 +243,39 @@ def test_ollama_not_running(self): # --------------------------------------------------------------------------- +def _not_found_error() -> NotFoundError: + """Build a NotFoundError backed by a mock 404 response for `get_secret`.""" + resp = MagicMock(spec=httpx.Response) + resp.status_code = 404 + resp.json.side_effect = ValueError("no body") + resp.text = "not found" + resp.reason_phrase = "Not Found" + return NotFoundError(resp) + + +def _make_mock_secrets_client(*, secret_exists: bool = False) -> MagicMock: + """Build a mock typed SecretsClient with get/create/update as MagicMocks.""" + secrets = MagicMock() + secrets.get_secret = MagicMock() + secrets.create_secret = MagicMock() + secrets.update_secret = MagicMock() + if secret_exists: + secrets.get_secret.return_value = MagicMock() + else: + secrets.get_secret.side_effect = _not_found_error() + return secrets + + def _make_mock_client(*, provider_exists: bool = False, secret_exists: bool = False) -> MagicMock: - """Build a mock NeMoPlatform client with configurable provider/secret state.""" + """Build a mock NeMoPlatform client with configurable provider/secret state. + + The typed secrets client returned by ``client_from_platform`` (patched via + the ``_patch_secrets_client`` fixture) is stashed on ``client.mock_secrets`` + so tests can assert on ``get_secret``/``create_secret``/``update_secret``. + """ client = MagicMock() client.inference.providers = MagicMock(spec=ProvidersResource) - if secret_exists: - client.secrets.retrieve.return_value = MagicMock() - else: - client.secrets.retrieve.side_effect = Exception("not found") + client.mock_secrets = _make_mock_secrets_client(secret_exists=secret_exists) if provider_exists: client.inference.providers.retrieve.return_value = MagicMock() else: @@ -256,6 +283,29 @@ def _make_mock_client(*, provider_exists: bool = False, secret_exists: bool = Fa return client +@pytest.fixture(autouse=True) +def _patch_secrets_client(): + """Route ``client_from_platform(client, SecretsClient)`` to ``client.mock_secrets``. + + The secrets helpers in setup.py obtain a typed ``SecretsClient`` via + ``client_from_platform``. Real adaptation would run against a MagicMock and + blow up in ``raise_for_status``, so tests patch it to return the mock secrets + client attached to the mock platform client (falling back to a fresh mock for + plain ``MagicMock()`` clients that lack ``mock_secrets``). + """ + + def _resolve(client, _client_cls): + secrets = getattr(client, "mock_secrets", None) + if isinstance(secrets, MagicMock): + return secrets + fallback = _make_mock_secrets_client() + client.mock_secrets = fallback + return fallback + + with patch(f"{SETUP_MOD}.client_from_platform", side_effect=_resolve): + yield + + # --------------------------------------------------------------------------- # Provider creation # --------------------------------------------------------------------------- @@ -343,7 +393,7 @@ def test_openai_key_creates_provider(self): with patch.dict("os.environ", {"OPENAI_API_KEY": "sk-test123"}, clear=True): result = _auto_setup(client, "default") assert result is True - client.secrets.create.assert_called_once() + client.mock_secrets.create_secret.assert_called_once() client.inference.providers.create.assert_called_once() create_kwargs = client.inference.providers.create.call_args assert create_kwargs.kwargs["name"] == "openai" @@ -1188,10 +1238,13 @@ def test_existing_secret_gets_updated_with_new_value(self): api_key="new-key-value", workspace="default", ) - client.secrets.update.assert_called_once_with( - "nvidia-build-api-key", value="new-key-value", workspace="default" - ) - client.secrets.create.assert_not_called() + client.mock_secrets.update_secret.assert_called_once() + update_call = client.mock_secrets.update_secret.call_args + assert update_call.kwargs["name"] == "nvidia-build-api-key" + assert update_call.kwargs["workspace"] == "default" + assert isinstance(update_call.kwargs["body"], PlatformSecretUpdateRequest) + assert update_call.kwargs["body"].value.get_secret_value() == "new-key-value" + client.mock_secrets.create_secret.assert_not_called() client.inference.providers.create.assert_called_once() client.inference.providers.update.assert_not_called() @@ -1205,8 +1258,13 @@ def test_existing_provider_gets_updated_with_secret(self): api_key="my-key", workspace="default", ) - client.secrets.create.assert_called_once_with(name="nvidia-build-api-key", value="my-key", workspace="default") - client.secrets.update.assert_not_called() + client.mock_secrets.create_secret.assert_called_once() + create_call = client.mock_secrets.create_secret.call_args + assert create_call.kwargs["workspace"] == "default" + assert isinstance(create_call.kwargs["body"], PlatformSecretCreateRequest) + assert create_call.kwargs["body"].name == "nvidia-build-api-key" + assert create_call.kwargs["body"].value.get_secret_value() == "my-key" + client.mock_secrets.update_secret.assert_not_called() client.inference.providers.update.assert_called_once() call_kwargs = client.inference.providers.update.call_args.kwargs assert call_kwargs["api_key_secret_name"] == "nvidia-build-api-key" @@ -1223,9 +1281,9 @@ def test_fresh_install_creates_both(self): api_key="sk-test", workspace="default", ) - client.secrets.create.assert_called_once() + client.mock_secrets.create_secret.assert_called_once() client.inference.providers.create.assert_called_once() - client.secrets.update.assert_not_called() + client.mock_secrets.update_secret.assert_not_called() client.inference.providers.update.assert_not_called() def test_existing_provider_updated_with_extra_headers(self): @@ -1252,7 +1310,7 @@ def test_auto_setup_updates_existing_provider_secret_binding(self): with patch.dict("os.environ", {"OPENAI_API_KEY": "sk-new-key"}, clear=True): result = _auto_setup(client, "default") assert result is True - client.secrets.create.assert_called_once() + client.mock_secrets.create_secret.assert_called_once() client.inference.providers.update.assert_called_once() call_kwargs = client.inference.providers.update.call_args.kwargs assert call_kwargs["api_key_secret_name"] == "openai-api-key" @@ -1263,8 +1321,13 @@ def test_auto_setup_updates_existing_secret_value(self): with patch.dict("os.environ", {"NVIDIA_API_KEY": "nvapi-new"}, clear=True): result = _auto_setup(client, "default") assert result is True - client.secrets.update.assert_called_once_with("nvidia-build-api-key", value="nvapi-new", workspace="default") - client.secrets.create.assert_not_called() + client.mock_secrets.update_secret.assert_called_once() + update_call = client.mock_secrets.update_secret.call_args + assert update_call.kwargs["name"] == "nvidia-build-api-key" + assert update_call.kwargs["workspace"] == "default" + assert isinstance(update_call.kwargs["body"], PlatformSecretUpdateRequest) + assert update_call.kwargs["body"].value.get_secret_value() == "nvapi-new" + client.mock_secrets.create_secret.assert_not_called() # --------------------------------------------------------------------------- diff --git a/services/core/files/src/nmp/core/files/api/endpoint_helpers.py b/services/core/files/src/nmp/core/files/api/endpoint_helpers.py index 0a50cac1ca..7bdc8b7685 100644 --- a/services/core/files/src/nmp/core/files/api/endpoint_helpers.py +++ b/services/core/files/src/nmp/core/files/api/endpoint_helpers.py @@ -11,11 +11,11 @@ from fastapi.responses import StreamingResponse from nemo_platform import ( AsyncNeMoPlatform, - PermissionDeniedError, -) -from nemo_platform import ( - NotFoundError as APINotFoundError, ) +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.client.errors import NotFoundError as ClientNotFoundError +from nemo_platform_plugin.client.errors import PermissionDeniedError as ClientPermissionDeniedError +from nemo_platform_plugin.secrets.client import AsyncSecretsClient from nmp.common.auth import AuthClient from nmp.common.entities.client import EntityClient, EntityNotFoundError from nmp.common.entities.utils import parse_entity_ref @@ -278,16 +278,17 @@ async def download_with_cache( async def resolve_storage_secrets(storage: StorageConfig, workspace: str, sdk: AsyncNeMoPlatform) -> dict[str, str]: """Resolve all secret references in a storage config.""" secrets: dict[str, str] = {} + secrets_client = client_from_platform(sdk, AsyncSecretsClient) for key, secret_ref in storage.get_secret_references().items(): parsed = parse_entity_ref(secret_ref.root, workspace) try: - response = await sdk.secrets.access(parsed.name, workspace=parsed.workspace) + response = (await secrets_client.access_secret(name=parsed.name, workspace=parsed.workspace)).data() secrets[key] = response.value - except APINotFoundError: + except ClientNotFoundError: raise SecretNotFoundError( f"Secret '{parsed.workspace}/{parsed.name}' not found", ) - except PermissionDeniedError: + except ClientPermissionDeniedError: raise SecretAccessDeniedError( f"Access denied to secret '{parsed.workspace}/{parsed.name}'", ) diff --git a/services/core/files/tests/integration/external_storage/test_ngc_storage.py b/services/core/files/tests/integration/external_storage/test_ngc_storage.py index 6e259fc532..441c5d73ec 100644 --- a/services/core/files/tests/integration/external_storage/test_ngc_storage.py +++ b/services/core/files/tests/integration/external_storage/test_ngc_storage.py @@ -25,9 +25,12 @@ from nemo_platform import NeMoPlatform from nemo_platform_plugin.client.adapter import client_from_platform from nemo_platform_plugin.files.client import FilesClient +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from nmp.core.files.app.backends.base import StorageImpl from nmp.core.files.app.streaming import download_url_streaming from nmp.core.files.testing.utils import create_fileset +from pydantic import SecretStr # Skip all tests in this module if NGC_API_KEY is not set pytestmark = pytest.mark.skipif( @@ -48,15 +51,15 @@ def ngc_api_key_secret(sdk: NeMoPlatform) -> Iterator[str]: if not api_key: pytest.fail("NGC_API_KEY environment variable must be set") secret_name = f"ngc-api-key-{uuid.uuid4().hex[:8]}" - sdk.secrets.create( + secrets = client_from_platform(sdk, SecretsClient) + secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(api_key)), workspace=DEFAULT_WORKSPACE, - name=secret_name, - data=api_key, ) try: yield secret_name finally: - sdk.secrets.delete(workspace=DEFAULT_WORKSPACE, name=secret_name) + secrets.delete_secret(name=secret_name, workspace=DEFAULT_WORKSPACE) class TestNGCVersionResolution: diff --git a/services/core/files/tests/integration/external_storage/test_s3_storage.py b/services/core/files/tests/integration/external_storage/test_s3_storage.py index c2af373848..f0c7ffc9c7 100644 --- a/services/core/files/tests/integration/external_storage/test_s3_storage.py +++ b/services/core/files/tests/integration/external_storage/test_s3_storage.py @@ -40,6 +40,8 @@ from nemo_platform_plugin.client.errors import NemoHTTPError as ClientBadRequestError from nemo_platform_plugin.files.client import FilesClient from nemo_platform_plugin.files.types import CreateFilesetRequest, FilesetOutput +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from nmp.common.auth import AuthClient, get_auth_client from nmp.common.auth.models import Principal from nmp.common.config import AuthConfig @@ -49,6 +51,7 @@ from nmp.core.files.testing.utils import create_fileset from nmp.core.secrets.service import SecretsService from nmp.testing import create_test_client +from pydantic import SecretStr from types_aiobotocore_s3 import S3Client # Test configuration - uses RustFS by default @@ -123,14 +126,21 @@ def s3_credentials(sdk: NeMoPlatform) -> Iterator[tuple[str, str]]: access_key_secret = f"s3-access-key-{uuid.uuid4().hex[:8]}" secret_key_secret = f"s3-secret-key-{uuid.uuid4().hex[:8]}" - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=access_key_secret, value=S3_TEST_ACCESS_KEY) - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_key_secret, value=S3_TEST_SECRET_KEY) + secrets = client_from_platform(sdk, SecretsClient) + secrets.create_secret( + body=PlatformSecretCreateRequest(name=access_key_secret, value=SecretStr(S3_TEST_ACCESS_KEY)), + workspace=DEFAULT_WORKSPACE, + ) + secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_key_secret, value=SecretStr(S3_TEST_SECRET_KEY)), + workspace=DEFAULT_WORKSPACE, + ) try: yield (access_key_secret, secret_key_secret) finally: - sdk.secrets.delete(workspace=DEFAULT_WORKSPACE, name=access_key_secret) - sdk.secrets.delete(workspace=DEFAULT_WORKSPACE, name=secret_key_secret) + secrets.delete_secret(name=access_key_secret, workspace=DEFAULT_WORKSPACE) + secrets.delete_secret(name=secret_key_secret, workspace=DEFAULT_WORKSPACE) @pytest.fixture @@ -195,8 +205,15 @@ def test_invalid_credentials(self, sdk: NeMoPlatform, s3_test_bucket: str): bad_access_secret = f"bad-s3-access-{uuid.uuid4().hex[:8]}" bad_secret_secret = f"bad-s3-secret-{uuid.uuid4().hex[:8]}" - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=bad_access_secret, value="invalid-key") - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=bad_secret_secret, value="invalid-secret") + secrets = client_from_platform(sdk, SecretsClient) + secrets.create_secret( + body=PlatformSecretCreateRequest(name=bad_access_secret, value=SecretStr("invalid-key")), + workspace=DEFAULT_WORKSPACE, + ) + secrets.create_secret( + body=PlatformSecretCreateRequest(name=bad_secret_secret, value=SecretStr("invalid-secret")), + workspace=DEFAULT_WORKSPACE, + ) try: files = client_from_platform(sdk, FilesClient) @@ -212,8 +229,8 @@ def test_invalid_credentials(self, sdk: NeMoPlatform, s3_test_bucket: str): assert exc_info.value.status_code == 400 assert "Access denied" in str(exc_info.value) or "credentials" in str(exc_info.value).lower() finally: - sdk.secrets.delete(workspace=DEFAULT_WORKSPACE, name=bad_access_secret) - sdk.secrets.delete(workspace=DEFAULT_WORKSPACE, name=bad_secret_secret) + secrets.delete_secret(name=bad_access_secret, workspace=DEFAULT_WORKSPACE) + secrets.delete_secret(name=bad_secret_secret, workspace=DEFAULT_WORKSPACE) def test_upload_and_download_roundtrip(self, sdk: NeMoPlatform, s3_fileset: FilesetOutput, tmp_path): """Test upload file, download it back, verify content matches.""" diff --git a/services/core/files/tests/integration/test_filesets_allowed_hosts.py b/services/core/files/tests/integration/test_filesets_allowed_hosts.py index 49e63d6ae3..6a6610125b 100644 --- a/services/core/files/tests/integration/test_filesets_allowed_hosts.py +++ b/services/core/files/tests/integration/test_filesets_allowed_hosts.py @@ -12,6 +12,8 @@ from nemo_platform_plugin.client.errors import NemoHTTPError from nemo_platform_plugin.files.client import FilesClient from nemo_platform_plugin.files.types import CreateFilesetRequest +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from nmp.common.auth import AuthClient, get_auth_client from nmp.common.auth.models import Principal from nmp.common.config import AuthConfig, Configuration @@ -19,6 +21,7 @@ from nmp.core.files.service import FilesService from nmp.core.secrets.service import SecretsService from nmp.testing import create_test_client +from pydantic import SecretStr # Mock auth so fileset create endpoint works (same pattern as integration/conftest.py) _mock_auth_principal = Principal(id="test@example.com") @@ -70,7 +73,11 @@ def test_create_ngc_fileset_with_disallowed_host_rejected( """Creating an NGC fileset with host outside allowed_external_hosts returns 400.""" sdk = sdk_with_restrictive_hosts secret_name = f"ngc-dummy-{uuid.uuid4().hex[:8]}" - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_name, value="nvapi-dummy") + secrets = client_from_platform(sdk, SecretsClient) + secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("nvapi-dummy")), + workspace=DEFAULT_WORKSPACE, + ) try: name = f"ngc-disallowed-{uuid.uuid4().hex[:8]}" with pytest.raises(NemoHTTPError) as exc_info: @@ -95,7 +102,7 @@ def test_create_ngc_fileset_with_disallowed_host_rejected( ) finally: try: - sdk.secrets.delete(workspace=DEFAULT_WORKSPACE, name=secret_name) + secrets.delete_secret(name=secret_name, workspace=DEFAULT_WORKSPACE) except Exception: pass diff --git a/services/core/files/tests/integration/tests_filesets_with_auth_secrets.py b/services/core/files/tests/integration/tests_filesets_with_auth_secrets.py index 626bb342af..71a45c8d02 100644 --- a/services/core/files/tests/integration/tests_filesets_with_auth_secrets.py +++ b/services/core/files/tests/integration/tests_filesets_with_auth_secrets.py @@ -13,6 +13,8 @@ from nemo_platform_plugin.client.errors import NemoHTTPError from nemo_platform_plugin.files.client import FilesClient from nemo_platform_plugin.files.types import CreateFilesetRequest +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from nmp.core.auth.app.bundle import ( build_authorization_data as _real_build_authorization_data, ) @@ -27,6 +29,7 @@ short_unique_name, unique_email, ) +from pydantic import SecretStr async def _build_authorization_data_without_secrets_read(entities_client=None): @@ -101,7 +104,10 @@ def test_editor_can_create_hf_fileset_with_token_secret( admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) admin_sdk.workspaces.create(name=workspace) - admin_sdk.secrets.create(workspace=workspace, name=secret_name, value="hf_dummy_token") + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("hf_dummy_token")), + workspace=workspace, + ) grant_workspace_role( admin_sdk, workspace=workspace, @@ -140,7 +146,10 @@ def test_custom_role_without_secrets_read_denied_with_token_secret( admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) admin_sdk.workspaces.create(name=workspace) - admin_sdk.secrets.create(workspace=workspace, name=secret_name, value="hf_dummy_token") + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("hf_dummy_token")), + workspace=workspace, + ) grant_workspace_role( admin_sdk, workspace=workspace, @@ -260,7 +269,10 @@ async def _list_files_noop(self, path=None): admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) admin_sdk.workspaces.create(name=workspace) - admin_sdk.secrets.create(workspace=workspace, name=secret_name, value="hf_dummy_token") + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("hf_dummy_token")), + workspace=workspace, + ) grant_workspace_role( admin_sdk, workspace=workspace, diff --git a/services/core/files/tests/test_endpoint_helpers.py b/services/core/files/tests/test_endpoint_helpers.py index b2cc6515b1..82dd7d2e3f 100644 --- a/services/core/files/tests/test_endpoint_helpers.py +++ b/services/core/files/tests/test_endpoint_helpers.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from nemo_platform_plugin.client.errors import NotFoundError as ClientNotFoundError from nmp.common.api.common import SecretRef from nmp.common.secrets.exceptions import SecretNotFoundError from nmp.core.files.api.endpoint_helpers import ( @@ -27,17 +28,38 @@ @pytest.fixture def mock_sdk(): - """Mock the platform SDK.""" - with patch("nmp.common.sdk_factory.get_async_platform_sdk") as mock: + """Mock the platform SDK and the SecretsClient it is adapted into. + + ``resolve_storage_secrets`` now wraps the SDK with + ``client_from_platform(sdk, AsyncSecretsClient)`` and calls + ``access_secret(...).data()``, so we patch the adapter to return a mock + secrets client and drive that. ``mock_sdk.access_secret`` is the AsyncMock + whose ``.return_value`` / ``.side_effect`` the tests set; the returned + object's ``.data()`` yields the response model. + """ + secrets_client = MagicMock() + secrets_client.access_secret = AsyncMock() + with ( + patch("nmp.common.sdk_factory.get_async_platform_sdk") as mock, + patch("nmp.core.files.api.endpoint_helpers.client_from_platform", return_value=secrets_client), + ): sdk = MagicMock() - sdk.secrets.access = AsyncMock() mock.return_value = sdk + # Expose the secrets-client mock as the handle tests configure/assert on. + sdk.access_secret = secrets_client.access_secret yield sdk +def _access_result(value: str) -> MagicMock: + """Build an object mimicking ``NemoResponse`` — ``.data().value``.""" + resp = MagicMock() + resp.data.return_value = MagicMock(value=value) + return resp + + async def test_resolve_hf_storage_with_token(mock_sdk): """Test resolving secrets for HuggingFace storage with token_secret.""" - mock_sdk.secrets.access.return_value = MagicMock(value="hf_token_value") + mock_sdk.access_secret.return_value = _access_result("hf_token_value") config = HuggingfaceStorageConfig( repo_id="org/repo", @@ -47,7 +69,7 @@ async def test_resolve_hf_storage_with_token(mock_sdk): secrets = await resolve_storage_secrets(config, "my-workspace", mock_sdk) assert secrets == {"token": "hf_token_value"} - mock_sdk.secrets.access.assert_called_once_with("my-hf-token", workspace="my-workspace") + mock_sdk.access_secret.assert_called_once_with(name="my-hf-token", workspace="my-workspace") async def test_resolve_hf_storage_without_token(mock_sdk): @@ -57,7 +79,7 @@ async def test_resolve_hf_storage_without_token(mock_sdk): secrets = await resolve_storage_secrets(config, "default", mock_sdk) assert secrets == {} - mock_sdk.secrets.access.assert_not_called() + mock_sdk.access_secret.assert_not_called() async def test_resolve_local_storage(mock_sdk): @@ -67,12 +89,12 @@ async def test_resolve_local_storage(mock_sdk): secrets = await resolve_storage_secrets(config, "default", mock_sdk) assert secrets == {} - mock_sdk.secrets.access.assert_not_called() + mock_sdk.access_secret.assert_not_called() async def test_resolve_ngc_storage(mock_sdk): """Test resolving secrets for NGC storage with qualified secret ref (workspace/name).""" - mock_sdk.secrets.access.return_value = MagicMock(value="ngc_api_key_value") + mock_sdk.access_secret.return_value = _access_result("ngc_api_key_value") # Use qualified format: shared-workspace/shared-ngc-key config = NGCStorageConfig( @@ -86,12 +108,12 @@ async def test_resolve_ngc_storage(mock_sdk): assert secrets == {"api_key": "ngc_api_key_value"} # Should use workspace from the qualified ref, not the default - mock_sdk.secrets.access.assert_called_once_with("shared-ngc-key", workspace="shared-workspace") + mock_sdk.access_secret.assert_called_once_with(name="shared-ngc-key", workspace="shared-workspace") async def test_resolve_storage_secrets_propagates_not_found(mock_sdk): - """Test that SecretNotFoundError from secrets service is propagated.""" - mock_sdk.secrets.access.side_effect = SecretNotFoundError("Secret not found") + """A 404 from the secrets service is mapped to SecretNotFoundError.""" + mock_sdk.access_secret.side_effect = ClientNotFoundError(MagicMock(status_code=404)) config = HuggingfaceStorageConfig( repo_id="org/repo", diff --git a/services/core/files/tests/test_otlp_ingest.py b/services/core/files/tests/test_otlp_ingest.py index 389ed680b6..51b374c58e 100644 --- a/services/core/files/tests/test_otlp_ingest.py +++ b/services/core/files/tests/test_otlp_ingest.py @@ -70,7 +70,6 @@ def mock_storage(): def mock_sdk(): """Create a mock SDK for testing.""" mock_sdk = AsyncMock() - mock_sdk.secrets.access = AsyncMock() return mock_sdk diff --git a/services/core/inference-gateway/src/nmp/core/inference_gateway/api/proxy.py b/services/core/inference-gateway/src/nmp/core/inference_gateway/api/proxy.py index c02a5436ae..7b406c1ab2 100644 --- a/services/core/inference-gateway/src/nmp/core/inference_gateway/api/proxy.py +++ b/services/core/inference-gateway/src/nmp/core/inference_gateway/api/proxy.py @@ -17,8 +17,9 @@ from jinja2 import Environment as JinjaEnvironment from multidict import CIMultiDict, CIMultiDictProxy from nemo_platform import AsyncNeMoPlatform -from nemo_platform import NotFoundError as SDKNotFoundError from nemo_platform.types.inference.virtual_model import VirtualModel as SDKVirtualModel +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.client.errors import NotFoundError as ClientNotFoundError from nemo_platform_plugin.inference_middleware import ( BackendFormat, ImmediateResponse, @@ -26,6 +27,7 @@ InferenceMiddlewareError, InferenceResponse, ) +from nemo_platform_plugin.secrets.client import AsyncSecretsClient from nmp.common.entities.utils import parse_model_entity_ref from nmp.core.inference_gateway.api.backend_format import resolve_backend_format from nmp.core.inference_gateway.api.errors import ( @@ -1041,7 +1043,8 @@ async def retrieve_secret_value(workspace: str, secret_name: str, secrets_sdk: A """ try: logger.debug(f"Retrieving API key from secrets service: {workspace}/{secret_name}") - response = await secrets_sdk.secrets.access(secret_name, workspace=workspace) + secrets = client_from_platform(secrets_sdk, AsyncSecretsClient) + response = (await secrets.access_secret(name=secret_name, workspace=workspace)).data() api_key = response.value if not api_key: @@ -1050,7 +1053,7 @@ async def retrieve_secret_value(workspace: str, secret_name: str, secrets_sdk: A logger.debug(f"Successfully retrieved API key from secret {workspace}/{secret_name}") return api_key - except SDKNotFoundError as e: + except ClientNotFoundError as e: logger.error(f"API key secret not found: {workspace}/{secret_name}") raise HTTPException(status_code=500, detail=f"API key secret not found: {workspace}/{secret_name}") from e except HTTPException: diff --git a/services/core/inference-gateway/src/nmp/core/inference_gateway/testing/harness.py b/services/core/inference-gateway/src/nmp/core/inference_gateway/testing/harness.py index e60e188c9b..861962496b 100644 --- a/services/core/inference-gateway/src/nmp/core/inference_gateway/testing/harness.py +++ b/services/core/inference-gateway/src/nmp/core/inference_gateway/testing/harness.py @@ -35,8 +35,11 @@ from nemo_platform.types.inference.middleware_call_param import MiddlewareCallParam from nemo_platform.types.inference.virtual_model import VirtualModel as SDKVirtualModel from nemo_platform.types.inference.virtual_model_inference_config_param import VirtualModelInferenceConfigParam +from nemo_platform_plugin.client.adapter import client_from_platform from nemo_platform_plugin.discovery import discover_inference_middleware from nemo_platform_plugin.inference_middleware import NemoInferenceMiddleware +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from nmp.common.entities.client import EntityClient from nmp.core.inference_gateway.api.dependencies import ( global_middleware_registry, @@ -61,6 +64,7 @@ MockResponse, RecordedRequest, ) +from pydantic import SecretStr from pytest_httpserver import HTTPServer logger = logging.getLogger(__name__) @@ -221,9 +225,10 @@ def _cleanup(self) -> None: exc_info=True, ) + secrets = client_from_platform(self.sdk, SecretsClient) for workspace, name in reversed(self._secrets): try: - self.sdk.secrets.delete(name=name, workspace=workspace) + secrets.delete_secret(name=name, workspace=workspace) except Exception: # noqa: BLE001 # see _cleanup docstring logger.warning( "Failed to delete Secret %r in workspace %r during harness cleanup", @@ -428,7 +433,7 @@ def create_secret( ) -> str: """Create a Secret and track it so the harness deletes it on teardown. - Prefer this over a direct ``self.sdk.secrets.create(...)`` — only + Prefer this over a direct ``SecretsClient.create_secret(...)`` — only harness-tracked entities get cleaned up, and an untracked secret will leak across tests under module scope (and keep a deleted provider's ``api_key_secret_name`` alive on the next refresh). @@ -436,10 +441,11 @@ def create_secret( Returns *name* so it chains cleanly into :meth:`add_provider` (``api_key_secret_name=harness.create_secret(...)``). """ - kwargs: dict[str, Any] = {"workspace": workspace, "name": name, "value": value} - if description is not None: - kwargs["description"] = description - self.sdk.secrets.create(**kwargs) + secrets = client_from_platform(self.sdk, SecretsClient) + secrets.create_secret( + body=PlatformSecretCreateRequest(name=name, value=SecretStr(value), description=description), + workspace=workspace, + ) self._secrets.append((workspace, name)) return name diff --git a/services/core/inference-gateway/tests/unit/conftest.py b/services/core/inference-gateway/tests/unit/conftest.py index 4a186151bf..a81eea9334 100644 --- a/services/core/inference-gateway/tests/unit/conftest.py +++ b/services/core/inference-gateway/tests/unit/conftest.py @@ -236,9 +236,4 @@ def mock_nmp_sdk(): This mocks AsyncNeMoPlatform for use with the inference gateway. """ m = AsyncMock() - m.secrets = AsyncMock() - # Create a mock response object with a data attribute - mock_response = AsyncMock() - mock_response.data = "test-api-key-12345" - m.secrets.access = AsyncMock(return_value=mock_response) return m diff --git a/services/core/jobs/src/nmp/core/jobs/app/dispatcher.py b/services/core/jobs/src/nmp/core/jobs/app/dispatcher.py index bdc12a4bf5..907494af4f 100644 --- a/services/core/jobs/src/nmp/core/jobs/app/dispatcher.py +++ b/services/core/jobs/src/nmp/core/jobs/app/dispatcher.py @@ -6,11 +6,13 @@ import logging from typing import Any, Dict, List, Optional, Tuple -from nemo_platform import AsyncNeMoPlatform, NotFoundError, PermissionDeniedError +from nemo_platform import AsyncNeMoPlatform from nemo_platform_plugin.client.adapter import client_from_platform from nemo_platform_plugin.client.errors import NotFoundError as ClientNotFoundError +from nemo_platform_plugin.client.errors import PermissionDeniedError as ClientPermissionDeniedError from nemo_platform_plugin.files.client import AsyncFilesClient from nemo_platform_plugin.files.types import CreateFilesetRequest +from nemo_platform_plugin.secrets.client import AsyncSecretsClient from nmp.common.api.filter import ComparisonOperation, FilterOperation, FilterOperator, LogicalOperation from nmp.common.api.in_memory_filter import InMemoryFilterRepository from nmp.common.api.parsed_filter import ParsedFilter @@ -217,11 +219,12 @@ async def validate_job_secrets( for env_var in step.environment: if env_var.from_secret: workspace, secret_name = get_entity_parts(env_var.from_secret.name, default_workspace=job_workspace) + secrets = client_from_platform(sdk_to_use, AsyncSecretsClient) try: - await sdk_to_use.secrets.retrieve(secret_name, workspace=workspace) - except NotFoundError: + await secrets.get_secret(name=secret_name, workspace=workspace) + except ClientNotFoundError: raise ValueError(f"Secret '{workspace}/{secret_name}' not found.") - except PermissionDeniedError: + except ClientPermissionDeniedError: raise ValueError(f"User does not have access to secret '{workspace}/{secret_name}'.") except Exception: logger.exception( diff --git a/services/core/jobs/tests/integration/test_jobs_secrets_access.py b/services/core/jobs/tests/integration/test_jobs_secrets_access.py index 5ed5e411f7..a151106a8e 100644 --- a/services/core/jobs/tests/integration/test_jobs_secrets_access.py +++ b/services/core/jobs/tests/integration/test_jobs_secrets_access.py @@ -17,6 +17,9 @@ import pytest from nemo_platform import NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from nmp.core.files.service import FilesService from nmp.core.jobs.service import JobsService from nmp.core.secrets.service import SecretsService @@ -28,6 +31,7 @@ short_unique_name, unique_email, ) +from pydantic import SecretStr @pytest.fixture(scope="module") @@ -77,10 +81,9 @@ def test_create_job_with_secret_user_has_access_succeeds(self, sdk: NeMoPlatform user_email = unique_email("editor") admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) - admin_sdk.secrets.create( + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("secret-value-for-job")), workspace=workspace, - name=secret_name, - value="secret-value-for-job", ) grant_workspace_role( admin_sdk, @@ -120,10 +123,9 @@ def test_create_job_with_secret_user_lacks_access_fails(self, sdk: NeMoPlatform) roles=["Editor"], ) # Secret only in workspace_other; user is not a member of workspace_other - admin_sdk.secrets.create( + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("secret-in-other-ws")), workspace=workspace_other, - name=secret_name, - value="secret-in-other-ws", ) user_sdk = as_user(sdk, user_email) diff --git a/services/core/jobs/tests/integration/test_task_auth_runtime.py b/services/core/jobs/tests/integration/test_task_auth_runtime.py index c2d9a612fa..b46097132c 100644 --- a/services/core/jobs/tests/integration/test_task_auth_runtime.py +++ b/services/core/jobs/tests/integration/test_task_auth_runtime.py @@ -17,7 +17,10 @@ from typing import Protocol import pytest -from nemo_platform import PermissionDeniedError +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.client.errors import PermissionDeniedError +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from nmp.core.secrets.service import SecretsService from nmp.testing import ( TEST_ADMIN_EMAIL, @@ -28,6 +31,7 @@ short_unique_name, unique_email, ) +from pydantic import SecretStr class _SecretAccessTask(Protocol): @@ -43,11 +47,12 @@ def run(*, http_client) -> str: workspace = os.environ["NEMO_JOB_WORKSPACE"] secret_name = os.environ["NEMO_TEST_SECRET_NAME"] - result = get_task_sdk(as_service="jobs", http_client=http_client).secrets.access( - workspace=workspace, + task_sdk = get_task_sdk(as_service="jobs", http_client=http_client) + result = client_from_platform(task_sdk, SecretsClient).access_secret( name=secret_name, + workspace=workspace, ) - return result.value + return result.data().value return _Task() @@ -67,7 +72,10 @@ def test_task_sdk_accesses_secret_on_behalf_of_creator(self): workspaces=[workspace], ) as ctx: admin_sdk = as_user(ctx.sdk, TEST_ADMIN_EMAIL) - admin_sdk.secrets.create(workspace=workspace, name=secret_name, value=secret_value) + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), + workspace=workspace, + ) grant_workspace_role( admin_sdk, workspace=workspace, @@ -115,7 +123,10 @@ def test_task_sdk_denies_secret_access_when_creator_lacks_permission(self): workspaces=[workspace], ) as ctx: admin_sdk = as_user(ctx.sdk, TEST_ADMIN_EMAIL) - admin_sdk.secrets.create(workspace=workspace, name=secret_name, value="secret-value") + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("secret-value")), + workspace=workspace, + ) ctx.access_log.clear() with ( diff --git a/services/core/models/src/nmp/core/models/api/permissions.py b/services/core/models/src/nmp/core/models/api/permissions.py index 1dfb33d3ad..8a6dd18816 100644 --- a/services/core/models/src/nmp/core/models/api/permissions.py +++ b/services/core/models/src/nmp/core/models/api/permissions.py @@ -13,12 +13,12 @@ """ from nemo_platform import AsyncNeMoPlatform -from nemo_platform._exceptions import NotFoundError, PermissionDeniedError from nemo_platform_plugin.client.adapter import client_from_platform from nemo_platform_plugin.client.errors import NotFoundError as ClientNotFoundError from nemo_platform_plugin.client.errors import PermissionDeniedError as ClientPermissionDeniedError from nemo_platform_plugin.files.client import AsyncFilesClient from nemo_platform_plugin.files.types import FilesetOutput +from nemo_platform_plugin.secrets.client import AsyncSecretsClient from nmp.common.auth import AuthClient from nmp.common.entities.utils import parse_entity_ref @@ -30,11 +30,12 @@ async def check_secret_access(nmp_sdk: AsyncNeMoPlatform, secret_name: str, work PermissionError: If the user cannot access the secret. ValueError: If the secret doesn't exist. """ + secrets = client_from_platform(nmp_sdk, AsyncSecretsClient) try: - await nmp_sdk.secrets.retrieve(secret_name, workspace=workspace) - except PermissionDeniedError: + await secrets.get_secret(name=secret_name, workspace=workspace) + except ClientPermissionDeniedError: raise PermissionError(f"Access denied to secret '{secret_name}' in workspace '{workspace}'") from None - except NotFoundError: + except ClientNotFoundError: raise ValueError(f"Secret '{secret_name}' not found in workspace '{workspace}'") from None diff --git a/services/core/models/src/nmp/core/models/controllers/backends/docker/backend.py b/services/core/models/src/nmp/core/models/controllers/backends/docker/backend.py index 6cd1d174d8..860980ee64 100644 --- a/services/core/models/src/nmp/core/models/controllers/backends/docker/backend.py +++ b/services/core/models/src/nmp/core/models/controllers/backends/docker/backend.py @@ -16,8 +16,10 @@ import httpx from docker.errors import APIError, NotFound -from nemo_platform import NotFoundError from nemo_platform.types.inference.model_deployment import ModelDeployment +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.client.errors import NotFoundError +from nemo_platform_plugin.secrets.client import AsyncSecretsClient from nmp.common.config import get_platform_config from nmp.common.docker.gpu_pool import DockerGPUPool from nmp.common.resources import SharedResourceManager @@ -150,7 +152,8 @@ async def _resolve_ngc_api_key(self) -> str | None: return os.environ.get(get_platform_config().ngc_api_key_env_var) or None workspace, name = parts[0], parts[1] try: - response = await self._nmp_sdk.secrets.access(name, workspace=workspace) + secrets = client_from_platform(self._nmp_sdk, AsyncSecretsClient) + response = (await secrets.access_secret(name=name, workspace=workspace)).data() if response.value: logger.debug("Resolved NGC API key from secret %s/%s", workspace, name) return response.value diff --git a/services/core/models/src/nmp/core/models/controllers/backends/docker/creation_reconciler.py b/services/core/models/src/nmp/core/models/controllers/backends/docker/creation_reconciler.py index 59cd43a0bf..8d92c431c5 100644 --- a/services/core/models/src/nmp/core/models/controllers/backends/docker/creation_reconciler.py +++ b/services/core/models/src/nmp/core/models/controllers/backends/docker/creation_reconciler.py @@ -27,7 +27,9 @@ from nemo_platform.types.inference.model_deployment import ModelDeployment from nemo_platform.types.inference.model_deployment_config import ModelDeploymentConfig from nemo_platform.types.models.model_entity import ModelEntity +from nemo_platform_plugin.client.adapter import client_from_platform from nemo_platform_plugin.jobs.image import get_qualified_image +from nemo_platform_plugin.secrets.client import AsyncSecretsClient from nmp.common.config import get_auth_config, get_platform_config from nmp.common.config.base import LOOPBACK_ADDRESSES from nmp.common.docker.gpu_pool import DockerGPUPool, GPUAllocationError @@ -1234,7 +1236,12 @@ async def _start_model_puller_container(self, state: CreationState) -> Container deployment.workspace, deployment.name, ) - response = await sdk.secrets.access(deployment.hf_token_secret_name, workspace=deployment.workspace) + secrets = client_from_platform(sdk, AsyncSecretsClient) + response = ( + await secrets.access_secret( + name=deployment.hf_token_secret_name, workspace=deployment.workspace + ) + ).data() hf_token = response.value logger.info("Retrieved HF token from secrets service") except Exception as e: diff --git a/services/core/models/tests/integration/test_models_with_auth.py b/services/core/models/tests/integration/test_models_with_auth.py index 9545aa93f4..cf0611dbfa 100644 --- a/services/core/models/tests/integration/test_models_with_auth.py +++ b/services/core/models/tests/integration/test_models_with_auth.py @@ -26,6 +26,8 @@ from nemo_platform_plugin.client.adapter import client_from_platform from nemo_platform_plugin.files.client import FilesClient from nemo_platform_plugin.files.types import CreateFilesetRequest +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from nmp.core.auth.app.bundle import build_authorization_data as _real_build_authorization_data from nmp.core.files.service import FilesService from nmp.core.models.config import config as models_config @@ -39,6 +41,7 @@ short_unique_name, unique_email, ) +from pydantic import SecretStr async def _build_authorization_data_without_secrets(entities_client=None): @@ -614,7 +617,10 @@ def test_editor_can_create_provider_with_secret(self, sdk: NeMoPlatform): admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) admin_sdk.workspaces.create(name=workspace) - admin_sdk.secrets.create(workspace=workspace, name="my-api-key", value="test-value") + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name="my-api-key", value=SecretStr("test-value")), + workspace=workspace, + ) grant_workspace_role( admin_sdk, workspace=workspace, @@ -639,7 +645,10 @@ def test_editor_can_upsert_provider_with_secret(self, sdk: NeMoPlatform): admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) admin_sdk.workspaces.create(name=workspace) - admin_sdk.secrets.create(workspace=workspace, name="my-api-key", value="test-value") + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name="my-api-key", value=SecretStr("test-value")), + workspace=workspace, + ) grant_workspace_role( admin_sdk, workspace=workspace, @@ -664,7 +673,10 @@ def test_custom_role_denied_create_provider_with_secret(self, sdk: NeMoPlatform) admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) admin_sdk.workspaces.create(name=workspace) - admin_sdk.secrets.create(workspace=workspace, name="should-be-denied", value="test") + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name="should-be-denied", value=SecretStr("test")), + workspace=workspace, + ) grant_workspace_role( admin_sdk, workspace=workspace, @@ -697,7 +709,10 @@ def test_custom_role_denied_upsert_provider_with_secret(self, sdk: NeMoPlatform) admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) admin_sdk.workspaces.create(name=workspace) - admin_sdk.secrets.create(workspace=workspace, name="should-be-denied", value="test") + client_from_platform(admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name="should-be-denied", value=SecretStr("test")), + workspace=workspace, + ) grant_workspace_role( admin_sdk, workspace=workspace, diff --git a/services/core/models/tests/unit/controllers/test_backend_registry.py b/services/core/models/tests/unit/controllers/test_backend_registry.py index 28fad677ba..655e310212 100644 --- a/services/core/models/tests/unit/controllers/test_backend_registry.py +++ b/services/core/models/tests/unit/controllers/test_backend_registry.py @@ -47,8 +47,6 @@ def mock_k8s_config(): def mock_nmp_sdk(): """Create a mock AsyncNeMoPlatform SDK.""" mock = AsyncMock() - mock.secrets = AsyncMock() - mock.secrets.access = AsyncMock(return_value=MagicMock(value="test-hf-token-value")) return mock diff --git a/services/core/models/tests/unit/controllers/test_docker_backend.py b/services/core/models/tests/unit/controllers/test_docker_backend.py index c327bec787..2b74ec592e 100644 --- a/services/core/models/tests/unit/controllers/test_docker_backend.py +++ b/services/core/models/tests/unit/controllers/test_docker_backend.py @@ -243,8 +243,6 @@ def get_reserved_gpu_ids(): def mock_nmp_sdk(): """Create a mock AsyncNeMoPlatform SDK.""" mock = AsyncMock() - mock.secrets = AsyncMock() - mock.secrets.access = AsyncMock(return_value=MagicMock(value="test-hf-token-value")) return mock diff --git a/services/core/models/tests/unit/controllers/test_k8s_nim_operator_backend.py b/services/core/models/tests/unit/controllers/test_k8s_nim_operator_backend.py index 12d87acfcb..4dc47fad8c 100644 --- a/services/core/models/tests/unit/controllers/test_k8s_nim_operator_backend.py +++ b/services/core/models/tests/unit/controllers/test_k8s_nim_operator_backend.py @@ -122,8 +122,6 @@ def _mock_pod_backend(k8s_backend, pod=None, *, pod_logs=""): def mock_nmp_sdk(): """Create a mock AsyncNeMoPlatform SDK.""" mock = AsyncMock() - mock.secrets = AsyncMock() - mock.secrets.access = AsyncMock(return_value=MagicMock(value="test-hf-token-value")) return mock diff --git a/services/core/models/tests/unit/test_model_deployment_service_unit.py b/services/core/models/tests/unit/test_model_deployment_service_unit.py index 827966d06a..49b44ad554 100644 --- a/services/core/models/tests/unit/test_model_deployment_service_unit.py +++ b/services/core/models/tests/unit/test_model_deployment_service_unit.py @@ -64,8 +64,6 @@ def mock_entity_client() -> AsyncMock: def mock_nmp_sdk() -> AsyncMock: """Create a mock NeMo Platform SDK for testing secret validation.""" mock = AsyncMock() - mock.secrets = AsyncMock() - mock.secrets.retrieve = AsyncMock() return mock diff --git a/services/core/secrets/src/nmp/core/secrets/api/v2/secrets/schemas.py b/services/core/secrets/src/nmp/core/secrets/api/v2/secrets/schemas.py index ebfb74c7ef..00771a2a7a 100644 --- a/services/core/secrets/src/nmp/core/secrets/api/v2/secrets/schemas.py +++ b/services/core/secrets/src/nmp/core/secrets/api/v2/secrets/schemas.py @@ -1,27 +1,38 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations +"""Secrets API schemas. -import re -from datetime import datetime, timezone -from typing import Self +The request/response contract lives in ``nemo_platform_plugin.secrets.types`` — +the shared single source of truth imported by both the server (here) and the +typed ``SecretsClient``. This module re-exports those models and adds the +server-only ``from_entity`` conversion helpers. +""" -from nmp.common.entities import constants -from nmp.core.secrets.entities import PlatformSecret -from pydantic import BaseModel, Field, SecretStr, field_validator, model_validator +from __future__ import annotations -_NAME_RE: re.Pattern[str] = re.compile(constants.REGEX_WORD_CHARACTER_DOT_DASH) +from nemo_platform_plugin.secrets.types import ( + PlatformSecretAccessResponse as PlatformSecretAccessResponse, +) +from nemo_platform_plugin.secrets.types import ( + PlatformSecretCreateRequest as PlatformSecretCreateRequest, +) +from nemo_platform_plugin.secrets.types import ( + PlatformSecretResponse as _PlatformSecretResponse, +) +from nemo_platform_plugin.secrets.types import ( + PlatformSecretUpdateRequest as PlatformSecretUpdateRequest, +) +from nmp.core.secrets.entities import PlatformSecret -class PlatformSecretResponse(BaseModel): +class PlatformSecretResponse(_PlatformSecretResponse): """Response model for a platform secret.""" - name: str = Field(..., description="The name of the secret") - workspace: str = Field(..., description="The workspace ID the secret belongs to") - description: str | None = Field(None, description="An optional description of the secret") - created_at: datetime | None = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime | None = Field(default_factory=lambda: datetime.now(timezone.utc)) + # NB: the docstring mirrors the base model's on purpose — FastAPI uses the + # response_model's docstring as the OpenAPI schema description, so a + # server-only note here would leak into the public API spec. This subclass + # exists solely to add ``from_entity``. @classmethod def from_entity(cls, secret: PlatformSecret) -> PlatformSecretResponse: @@ -33,51 +44,3 @@ def from_entity(cls, secret: PlatformSecret) -> PlatformSecretResponse: created_at=secret.created_at, updated_at=secret.updated_at, ) - - -class PlatformSecretCreateRequest(BaseModel): - """Request body for creating a new platform secret.""" - - name: str = Field( - ..., - description=f"The name of the secret to create. {constants.REGEX_WORD_CHARACTER_DOT_DASH_DESCRIPTION}", - examples=["hf-token", "wandb-api-key"], - ) - description: str | None = Field(None, description="An optional description of the secret") - value: SecretStr = Field(..., description="The payload of the secret") - - @field_validator("name") - @classmethod - def validate_name(cls, v: str) -> str: - if not _NAME_RE.match(v): - raise ValueError( - f"Invalid secret name '{v}'. {constants.REGEX_WORD_CHARACTER_DOT_DASH_DESCRIPTION} Example: my-api-key" - ) - return v - - @model_validator(mode="after") - def validate_self(self) -> Self: - if not self.value.get_secret_value(): - raise ValueError("Secret value cannot be empty") - return self - - -class PlatformSecretUpdateRequest(BaseModel): - """Request body for updating a platform secret's metadata.""" - - description: str | None = Field(None, description="An optional description of the secret") - value: SecretStr | None = Field(None, description="The new secret value") - - @model_validator(mode="after") - def validate_self(self) -> Self: - if self.value is not None and not self.value.get_secret_value(): - raise ValueError("Secret value cannot be empty") - return self - - -class PlatformSecretAccessResponse(BaseModel): - """Response model for accessing a platform secret's value.""" - - name: str = Field(..., description="The name of the secret") - workspace: str = Field(..., description="The workspace ID the secret belongs to") - value: str = Field(..., description="The payload of the secret") diff --git a/services/core/secrets/tests/conftest.py b/services/core/secrets/tests/conftest.py index ef879544af..d8e064f535 100644 --- a/services/core/secrets/tests/conftest.py +++ b/services/core/secrets/tests/conftest.py @@ -7,7 +7,7 @@ import pytest from fastapi.testclient import TestClient -from nemo_platform import NeMoPlatform +from nemo_platform_plugin.secrets.client import SecretsClient from nmp.common.secrets.encryption import get_base64_encoded_random_bytes from nmp.core.secrets.config import SecretsServiceConfig from nmp.core.secrets.service import SecretsService @@ -60,9 +60,9 @@ def test_client(service_config) -> Generator[TestClient, None, None]: @pytest.fixture -def sdk(test_client: TestClient) -> NeMoPlatform: - """SDK client backed by the test client.""" - return NeMoPlatform(base_url="http://testserver", http_client=test_client) +def sdk(test_client: TestClient) -> SecretsClient: + """Typed Secrets client backed by the test client.""" + return SecretsClient(base_url="http://testserver", workspace="default", http_client=test_client) @pytest.fixture diff --git a/services/core/secrets/tests/integration/test_secrets_with_auth.py b/services/core/secrets/tests/integration/test_secrets_with_auth.py index e48b9d4e81..f1caac08d6 100644 --- a/services/core/secrets/tests/integration/test_secrets_with_auth.py +++ b/services/core/secrets/tests/integration/test_secrets_with_auth.py @@ -16,7 +16,12 @@ from typing import Generator import pytest -from nemo_platform import NeMoPlatform, PermissionDeniedError, UnprocessableEntityError +from nemo_platform import NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.client.errors import PermissionDeniedError as ClientPermissionDeniedError +from nemo_platform_plugin.client.errors import UnprocessableEntityError as ClientUnprocessableEntityError +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest, PlatformSecretUpdateRequest from nmp.common.auth.models import Principal from nmp.common.sdk_factory import get_sdk_on_behalf_of from nmp.core.secrets.config import SecretsServiceConfig @@ -29,6 +34,7 @@ short_unique_name, unique_email, ) +from pydantic import SecretStr # Service principals have elevated access (like platform admin) SERVICE_PRINCIPAL = "service:integration-test" @@ -80,11 +86,11 @@ def test_viewer_can_list_secrets(self, sdk: NeMoPlatform): platform_admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) platform_admin_sdk.workspaces.create(name=workspace_name) - platform_admin_sdk.secrets.create( + platform_admin_secrets = client_from_platform(platform_admin_sdk, SecretsClient) + platform_admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("secret-value")), workspace=workspace_name, - name=secret_name, - value="secret-value", - ) + ).data() grant_workspace_role( platform_admin_sdk, workspace=workspace_name, @@ -94,8 +100,9 @@ def test_viewer_can_list_secrets(self, sdk: NeMoPlatform): # Test: viewer can list secrets viewer_sdk = as_user(sdk, viewer_email) - result = viewer_sdk.secrets.list(workspace=workspace_name) - secret_names = [s.name for s in result.data] + viewer_secrets = client_from_platform(viewer_sdk, SecretsClient) + resp = viewer_secrets.list_secrets(workspace=workspace_name) + secret_names = [s.name for s in resp.items()] assert secret_name in secret_names @@ -107,11 +114,11 @@ def test_viewer_can_get_secret_metadata(self, sdk: NeMoPlatform): platform_admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) platform_admin_sdk.workspaces.create(name=workspace_name) - platform_admin_sdk.secrets.create( + platform_admin_secrets = client_from_platform(platform_admin_sdk, SecretsClient) + platform_admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("secret-value")), workspace=workspace_name, - name=secret_name, - value="secret-value", - ) + ).data() grant_workspace_role( platform_admin_sdk, workspace=workspace_name, @@ -120,7 +127,8 @@ def test_viewer_can_get_secret_metadata(self, sdk: NeMoPlatform): ) viewer_sdk = as_user(sdk, viewer_email) - secret = viewer_sdk.secrets.retrieve(workspace=workspace_name, name=secret_name) + viewer_secrets = client_from_platform(viewer_sdk, SecretsClient) + secret = viewer_secrets.get_secret(name=secret_name, workspace=workspace_name).data() assert secret.name == secret_name assert secret.workspace == workspace_name @@ -133,11 +141,11 @@ def test_viewer_cannot_access_secret_value(self, sdk: NeMoPlatform): platform_admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) platform_admin_sdk.workspaces.create(name=workspace_name) - platform_admin_sdk.secrets.create( + platform_admin_secrets = client_from_platform(platform_admin_sdk, SecretsClient) + platform_admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("secret-value")), workspace=workspace_name, - name=secret_name, - value="secret-value", - ) + ).data() grant_workspace_role( platform_admin_sdk, workspace=workspace_name, @@ -146,8 +154,9 @@ def test_viewer_cannot_access_secret_value(self, sdk: NeMoPlatform): ) viewer_sdk = as_user(sdk, viewer_email) - with pytest.raises(PermissionDeniedError): - viewer_sdk.secrets.access(workspace=workspace_name, name=secret_name) + viewer_secrets = client_from_platform(viewer_sdk, SecretsClient) + with pytest.raises(ClientPermissionDeniedError): + viewer_secrets.access_secret(name=secret_name, workspace=workspace_name) def test_viewer_cannot_create_secret(self, sdk: NeMoPlatform): """Test that a Viewer cannot create secrets.""" @@ -164,11 +173,11 @@ def test_viewer_cannot_create_secret(self, sdk: NeMoPlatform): ) viewer_sdk = as_user(sdk, viewer_email) - with pytest.raises(PermissionDeniedError): - viewer_sdk.secrets.create( + viewer_secrets = client_from_platform(viewer_sdk, SecretsClient) + with pytest.raises(ClientPermissionDeniedError): + viewer_secrets.create_secret( + body=PlatformSecretCreateRequest(name=short_unique_name("new-sec"), value=SecretStr("should-fail")), workspace=workspace_name, - name=short_unique_name("new-sec"), - value="should-fail", ) def test_viewer_cannot_update_secret(self, sdk: NeMoPlatform): @@ -179,11 +188,11 @@ def test_viewer_cannot_update_secret(self, sdk: NeMoPlatform): platform_admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) platform_admin_sdk.workspaces.create(name=workspace_name) - platform_admin_sdk.secrets.create( + platform_admin_secrets = client_from_platform(platform_admin_sdk, SecretsClient) + platform_admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("original-value")), workspace=workspace_name, - name=secret_name, - value="original-value", - ) + ).data() grant_workspace_role( platform_admin_sdk, workspace=workspace_name, @@ -192,11 +201,12 @@ def test_viewer_cannot_update_secret(self, sdk: NeMoPlatform): ) viewer_sdk = as_user(sdk, viewer_email) - with pytest.raises(PermissionDeniedError): - viewer_sdk.secrets.update( - workspace=workspace_name, + viewer_secrets = client_from_platform(viewer_sdk, SecretsClient) + with pytest.raises(ClientPermissionDeniedError): + viewer_secrets.update_secret( name=secret_name, - value="should-fail", + body=PlatformSecretUpdateRequest(value=SecretStr("should-fail")), + workspace=workspace_name, ) def test_viewer_cannot_delete_secret(self, sdk: NeMoPlatform): @@ -207,11 +217,11 @@ def test_viewer_cannot_delete_secret(self, sdk: NeMoPlatform): platform_admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) platform_admin_sdk.workspaces.create(name=workspace_name) - platform_admin_sdk.secrets.create( + platform_admin_secrets = client_from_platform(platform_admin_sdk, SecretsClient) + platform_admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("secret-value")), workspace=workspace_name, - name=secret_name, - value="secret-value", - ) + ).data() grant_workspace_role( platform_admin_sdk, workspace=workspace_name, @@ -220,8 +230,9 @@ def test_viewer_cannot_delete_secret(self, sdk: NeMoPlatform): ) viewer_sdk = as_user(sdk, viewer_email) - with pytest.raises(PermissionDeniedError): - viewer_sdk.secrets.delete(workspace=workspace_name, name=secret_name) + viewer_secrets = client_from_platform(viewer_sdk, SecretsClient) + with pytest.raises(ClientPermissionDeniedError): + viewer_secrets.delete_secret(name=secret_name, workspace=workspace_name) @pytest.mark.integration @@ -243,12 +254,12 @@ def test_editor_can_create_secret(self, sdk: NeMoPlatform): ) editor_sdk = as_user(sdk, editor_email) + editor_secrets = client_from_platform(editor_sdk, SecretsClient) secret_name = short_unique_name("ed-sec") - secret = editor_sdk.secrets.create( + secret = editor_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("editor-created-secret")), workspace=workspace_name, - name=secret_name, - value="editor-created-secret", - ) + ).data() assert secret.name == secret_name assert secret.workspace == workspace_name @@ -268,15 +279,15 @@ def test_editor_can_list_secrets(self, sdk: NeMoPlatform): ) editor_sdk = as_user(sdk, editor_email) + editor_secrets = client_from_platform(editor_sdk, SecretsClient) secret_name = short_unique_name("list-sec") - editor_sdk.secrets.create( + editor_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("test-data")), workspace=workspace_name, - name=secret_name, - value="test-data", - ) + ).data() - result = editor_sdk.secrets.list(workspace=workspace_name) - secret_names = [s.name for s in result.data] + resp = editor_secrets.list_secrets(workspace=workspace_name) + secret_names = [s.name for s in resp.items()] assert secret_name in secret_names @@ -295,14 +306,14 @@ def test_editor_can_get_secret_metadata(self, sdk: NeMoPlatform): ) editor_sdk = as_user(sdk, editor_email) + editor_secrets = client_from_platform(editor_sdk, SecretsClient) secret_name = short_unique_name("get-sec") - editor_sdk.secrets.create( + editor_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("test-data")), workspace=workspace_name, - name=secret_name, - value="test-data", - ) + ).data() - secret = editor_sdk.secrets.retrieve(workspace=workspace_name, name=secret_name) + secret = editor_secrets.get_secret(name=secret_name, workspace=workspace_name).data() assert secret.name == secret_name def test_editor_can_update_secret(self, sdk: NeMoPlatform): @@ -320,18 +331,18 @@ def test_editor_can_update_secret(self, sdk: NeMoPlatform): ) editor_sdk = as_user(sdk, editor_email) + editor_secrets = client_from_platform(editor_sdk, SecretsClient) secret_name = short_unique_name("upd-sec") - editor_sdk.secrets.create( + editor_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("original-data")), workspace=workspace_name, - name=secret_name, - value="original-data", - ) + ).data() - updated = editor_sdk.secrets.update( - workspace=workspace_name, + updated = editor_secrets.update_secret( name=secret_name, - value="updated-data", - ) + body=PlatformSecretUpdateRequest(value=SecretStr("updated-data")), + workspace=workspace_name, + ).data() assert updated.name == secret_name def test_editor_can_delete_secret(self, sdk: NeMoPlatform): @@ -349,18 +360,18 @@ def test_editor_can_delete_secret(self, sdk: NeMoPlatform): ) editor_sdk = as_user(sdk, editor_email) + editor_secrets = client_from_platform(editor_sdk, SecretsClient) secret_name = short_unique_name("del-sec") - editor_sdk.secrets.create( + editor_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("to-be-deleted")), workspace=workspace_name, - name=secret_name, - value="to-be-deleted", - ) + ).data() - editor_sdk.secrets.delete(workspace=workspace_name, name=secret_name) + editor_secrets.delete_secret(name=secret_name, workspace=workspace_name) # Verify it's gone - result = editor_sdk.secrets.list(workspace=workspace_name) - secret_names = [s.name for s in result.data] + resp = editor_secrets.list_secrets(workspace=workspace_name) + secret_names = [s.name for s in resp.items()] assert secret_name not in secret_names def test_editor_cannot_access_secret_value(self, sdk: NeMoPlatform): @@ -378,15 +389,15 @@ def test_editor_cannot_access_secret_value(self, sdk: NeMoPlatform): ) editor_sdk = as_user(sdk, editor_email) + editor_secrets = client_from_platform(editor_sdk, SecretsClient) secret_name = short_unique_name("no-acc") - editor_sdk.secrets.create( + editor_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("secret-data")), workspace=workspace_name, - name=secret_name, - value="secret-data", - ) + ).data() - with pytest.raises(PermissionDeniedError): - editor_sdk.secrets.access(workspace=workspace_name, name=secret_name) + with pytest.raises(ClientPermissionDeniedError): + editor_secrets.access_secret(name=secret_name, workspace=workspace_name) @pytest.mark.skip("Need to add the ability to authenticate as admin for non-workspaced route") def test_editor_cannot_rotate_encryption_keys(self, sdk: NeMoPlatform): @@ -404,9 +415,10 @@ def test_editor_cannot_rotate_encryption_keys(self, sdk: NeMoPlatform): ) editor_sdk = as_user(sdk, editor_email) + editor_secrets = client_from_platform(editor_sdk, SecretsClient) - with pytest.raises(PermissionDeniedError): - editor_sdk.secrets.admin.rotate_encryption_keys() + with pytest.raises(ClientPermissionDeniedError): + editor_secrets.rotate_encryption_keys().data() @pytest.mark.integration @@ -429,12 +441,12 @@ def test_admin_can_create_secret(self, sdk: NeMoPlatform): ) admin_sdk = as_user(sdk, admin_email) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) secret_name = short_unique_name("adm-sec") - secret = admin_sdk.secrets.create( + secret = admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("admin-created-secret")), workspace=workspace_name, - name=secret_name, - value="admin-created-secret", - ) + ).data() assert secret.name == secret_name @@ -453,18 +465,18 @@ def test_admin_can_update_secret(self, sdk: NeMoPlatform): ) admin_sdk = as_user(sdk, admin_email) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) secret_name = short_unique_name("upd-sec") - admin_sdk.secrets.create( + admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("original-data")), workspace=workspace_name, - name=secret_name, - value="original-data", - ) + ).data() - admin_sdk.secrets.update( - workspace=workspace_name, + admin_secrets.update_secret( name=secret_name, - value="updated-by-admin", - ) + body=PlatformSecretUpdateRequest(value=SecretStr("updated-by-admin")), + workspace=workspace_name, + ).data() def test_admin_can_delete_secret(self, sdk: NeMoPlatform): """Test that an Admin can delete secrets.""" @@ -481,14 +493,14 @@ def test_admin_can_delete_secret(self, sdk: NeMoPlatform): ) admin_sdk = as_user(sdk, admin_email) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) secret_name = short_unique_name("del-sec") - admin_sdk.secrets.create( + admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("to-be-deleted")), workspace=workspace_name, - name=secret_name, - value="to-be-deleted", - ) + ).data() - admin_sdk.secrets.delete(workspace=workspace_name, name=secret_name) + admin_secrets.delete_secret(name=secret_name, workspace=workspace_name) def test_admin_cannot_access_secret_value(self, sdk: NeMoPlatform): """Test that an Admin cannot access the secret value via /access endpoint.""" @@ -505,15 +517,15 @@ def test_admin_cannot_access_secret_value(self, sdk: NeMoPlatform): ) admin_sdk = as_user(sdk, admin_email) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) secret_name = short_unique_name("no-acc") - admin_sdk.secrets.create( + admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("admin-secret")), workspace=workspace_name, - name=secret_name, - value="admin-secret", - ) + ).data() - with pytest.raises(PermissionDeniedError): - admin_sdk.secrets.access(workspace=workspace_name, name=secret_name) + with pytest.raises(ClientPermissionDeniedError): + admin_secrets.access_secret(name=secret_name, workspace=workspace_name) @pytest.mark.integration @@ -527,17 +539,17 @@ class TestPlatformAdminSecretsAccess: def test_platform_admin_cannot_access_secret_value(self, sdk: NeMoPlatform): """Test that a Platform Admin cannot directly access the secret value.""" admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) secret_name = short_unique_name("pa-sec") secret_value = "platform-admin-secret-value" - admin_sdk.secrets.create( + admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), workspace="default", - name=secret_name, - value=secret_value, - ) + ).data() - with pytest.raises(PermissionDeniedError): - admin_sdk.secrets.access(workspace="default", name=secret_name) + with pytest.raises(ClientPermissionDeniedError): + admin_secrets.access_secret(name=secret_name, workspace="default") def test_platform_admin_can_create_secret_in_any_workspace(self, sdk: NeMoPlatform): """Test that a Platform Admin can create secrets in any workspace.""" @@ -546,12 +558,12 @@ def test_platform_admin_can_create_secret_in_any_workspace(self, sdk: NeMoPlatfo admin_sdk.workspaces.create(name=workspace_name) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) secret_name = short_unique_name("pa-sec") - secret = admin_sdk.secrets.create( + secret = admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("secret-in-new-workspace")), workspace=workspace_name, - name=secret_name, - value="secret-in-new-workspace", - ) + ).data() assert secret.name == secret_name assert secret.workspace == workspace_name @@ -564,23 +576,23 @@ def test_platform_admin_can_rotate_encryption_keys(self, sdk: NeMoPlatform): encryption provider. This is an admin-only operation for key rotation scenarios. """ admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) # Create some secrets to ensure there's data to potentially rotate secret_name = short_unique_name("rot-sec") - admin_sdk.secrets.create( + admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("secret-for-rotation-test")), workspace="default", - name=secret_name, - value="secret-for-rotation-test", - ) + ).data() # Call the rotate encryption keys endpoint - response = sdk.secrets.admin.rotate_encryption_keys() + response = client_from_platform(sdk, SecretsClient).rotate_encryption_keys().data() assert response.success is True assert response.rotated_secrets >= 1 # Verify the secret is still accessible after rotation - result = admin_sdk.secrets.access(workspace="default", name=secret_name) + result = admin_secrets.access_secret(name=secret_name, workspace="default").data() assert result.value == "secret-for-rotation-test" @@ -591,17 +603,18 @@ class TestServiceCredentialsSecretsAccess: def test_service_credentials_can_access_secret_value(self, sdk: NeMoPlatform): """Test that service credentials can access the secret value via /access endpoint.""" admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) secret_name = short_unique_name("svc-sec") secret_value = "service-accessible-secret" - admin_sdk.secrets.create( + admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), workspace="default", - name=secret_name, - value=secret_value, - ) + ).data() service_sdk = as_user(sdk, SERVICE_PRINCIPAL) - result = service_sdk.secrets.access(workspace="default", name=secret_name) + service_secrets = client_from_platform(service_sdk, SecretsClient) + result = service_secrets.access_secret(name=secret_name, workspace="default").data() assert result.name == secret_name assert result.value == secret_value @@ -609,20 +622,21 @@ def test_service_credentials_can_access_secret_value(self, sdk: NeMoPlatform): def test_service_credentials_can_list_secrets(self, sdk: NeMoPlatform): """Test that service credentials can list secrets.""" service_sdk = as_user(sdk, SERVICE_PRINCIPAL) + service_secrets = client_from_platform(service_sdk, SecretsClient) - result = service_sdk.secrets.list(workspace="default") - assert result.data is not None + result = list(service_secrets.list_secrets(workspace="default").items()) + assert result is not None def test_service_credentials_can_create_secrets(self, sdk: NeMoPlatform): """Test that service credentials can create secrets.""" service_sdk = as_user(sdk, SERVICE_PRINCIPAL) + service_secrets = client_from_platform(service_sdk, SecretsClient) secret_name = short_unique_name("svc-crt") - secret = service_sdk.secrets.create( + secret = service_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("service-created-secret")), workspace="default", - name=secret_name, - value="service-created-secret", - ) + ).data() assert secret.name == secret_name @@ -634,13 +648,13 @@ class TestSecretDataNotExposed: def test_secret_data_not_in_create_response(self, sdk: NeMoPlatform): """Test that secret data is not returned in create response.""" admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) secret_name = short_unique_name("no-data") - secret = admin_sdk.secrets.create( + secret = admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("should-not-be-visible")), workspace="default", - name=secret_name, - value="should-not-be-visible", - ) + ).data() assert secret.name == secret_name @@ -657,13 +671,13 @@ def test_secret_data_not_in_create_response(self, sdk: NeMoPlatform): def test_secret_data_not_in_list_response(self, sdk: NeMoPlatform): """Test that secret data is not returned when listing secrets.""" admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) secret_name = short_unique_name("list-no") - admin_sdk.secrets.create( + admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("hidden-in-list")), workspace="default", - name=secret_name, - value="hidden-in-list", - ) + ).data() response = sdk._client.get( "/apis/secrets/v2/workspaces/default/secrets", @@ -678,13 +692,13 @@ def test_secret_data_not_in_list_response(self, sdk: NeMoPlatform): def test_secret_data_not_in_get_response(self, sdk: NeMoPlatform): """Test that secret data is not returned when getting a single secret.""" admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) secret_name = short_unique_name("get-no") - admin_sdk.secrets.create( + admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("hidden-in-get")), workspace="default", - name=secret_name, - value="hidden-in-get", - ) + ).data() response = sdk._client.get( f"/apis/secrets/v2/workspaces/default/secrets/{secret_name}", @@ -726,16 +740,19 @@ def test_service_principal_can_access_on_behalf_of_viewer(self, sdk: NeMoPlatfor principal=viewer_email, roles=["Viewer"], ) - platform_admin_sdk.secrets.create( + client_from_platform(platform_admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), workspace=workspace_name, - name=secret_name, - value=secret_value, - ) + ).data() # Service principal accesses secret on behalf of viewer # Viewer has secrets.read permission, so this should succeed delegated_sdk = get_sdk_on_behalf_of(as_user(sdk, SERVICE_PRINCIPAL), viewer_email) - result = delegated_sdk.secrets.access(workspace=workspace_name, name=secret_name) + result = ( + client_from_platform(delegated_sdk, SecretsClient) + .access_secret(name=secret_name, workspace=workspace_name) + .data() + ) assert result.name == secret_name assert result.value == secret_value @@ -756,11 +773,10 @@ def test_service_principal_can_access_on_behalf_of_group_bound_viewer(self, sdk: principal=delegated_group, roles=["Viewer"], ) - platform_admin_sdk.secrets.create( + client_from_platform(platform_admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), workspace=workspace_name, - name=secret_name, - value=secret_value, - ) + ).data() delegated_sdk = get_sdk_on_behalf_of( as_user(sdk, SERVICE_PRINCIPAL), @@ -771,7 +787,11 @@ def test_service_principal_can_access_on_behalf_of_group_bound_viewer(self, sdk: ), ) - result = delegated_sdk.secrets.access(workspace=workspace_name, name=secret_name) + result = ( + client_from_platform(delegated_sdk, SecretsClient) + .access_secret(name=secret_name, workspace=workspace_name) + .data() + ) assert result.name == secret_name assert result.value == secret_value @@ -786,17 +806,16 @@ def test_platform_admin_cannot_access_on_behalf_of_non_member(self, sdk: NeMoPla # Setup: create workspace and secret, but don't add the user as a member platform_admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) platform_admin_sdk.workspaces.create(name=workspace_name) - platform_admin_sdk.secrets.create( + client_from_platform(platform_admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), workspace=workspace_name, - name=secret_name, - value=secret_value, - ) + ).data() # Platform admin accesses secret on behalf of non-member # Non-member doesn't have any role in workspace, so this should fail delegated_sdk = get_sdk_on_behalf_of(as_user(sdk, TEST_ADMIN_EMAIL), non_member_email) - with pytest.raises(PermissionDeniedError): - delegated_sdk.secrets.access(workspace=workspace_name, name=secret_name) + with pytest.raises(ClientPermissionDeniedError): + client_from_platform(delegated_sdk, SecretsClient).access_secret(name=secret_name, workspace=workspace_name) def test_service_principal_denies_on_behalf_of_user_missing_group_bound_role(self, sdk: NeMoPlatform): """Test delegated access fails when the delegated user lacks the bound group.""" @@ -814,11 +833,10 @@ def test_service_principal_denies_on_behalf_of_user_missing_group_bound_role(sel principal=bound_group, roles=["Viewer"], ) - platform_admin_sdk.secrets.create( + client_from_platform(platform_admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr("delegated-group-secret")), workspace=workspace_name, - name=secret_name, - value="delegated-group-secret", - ) + ).data() delegated_sdk = get_sdk_on_behalf_of( as_user(sdk, SERVICE_PRINCIPAL), @@ -829,8 +847,8 @@ def test_service_principal_denies_on_behalf_of_user_missing_group_bound_role(sel ), ) - with pytest.raises(PermissionDeniedError): - delegated_sdk.secrets.access(workspace=workspace_name, name=secret_name) + with pytest.raises(ClientPermissionDeniedError): + client_from_platform(delegated_sdk, SecretsClient).access_secret(name=secret_name, workspace=workspace_name) def test_service_principal_can_access_on_behalf_of_editor(self, sdk: NeMoPlatform): """Test service principal accessing secret on behalf of an Editor.""" @@ -848,16 +866,19 @@ def test_service_principal_can_access_on_behalf_of_editor(self, sdk: NeMoPlatfor principal=editor_email, roles=["Editor"], ) - platform_admin_sdk.secrets.create( + client_from_platform(platform_admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), workspace=workspace_name, - name=secret_name, - value=secret_value, - ) + ).data() # Service principal accesses secret on behalf of editor # Editor has secrets.read permission (inherits from Viewer), so this should succeed delegated_sdk = get_sdk_on_behalf_of(as_user(sdk, SERVICE_PRINCIPAL), editor_email) - result = delegated_sdk.secrets.access(workspace=workspace_name, name=secret_name) + result = ( + client_from_platform(delegated_sdk, SecretsClient) + .access_secret(name=secret_name, workspace=workspace_name) + .data() + ) assert result.name == secret_name assert result.value == secret_value @@ -875,19 +896,23 @@ def test_delegated_access_without_on_behalf_of_uses_caller_permissions(self, sdk # Setup: create workspace and secret platform_admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) platform_admin_sdk.workspaces.create(name=workspace_name) - platform_admin_sdk.secrets.create( + platform_admin_secrets = client_from_platform(platform_admin_sdk, SecretsClient) + platform_admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), workspace=workspace_name, - name=secret_name, - value=secret_value, - ) + ).data() # Platform admin cannot access secret directly (denied by OPA deny rule) - with pytest.raises(PermissionDeniedError): - platform_admin_sdk.secrets.access(workspace=workspace_name, name=secret_name) + with pytest.raises(ClientPermissionDeniedError): + platform_admin_secrets.access_secret(name=secret_name, workspace=workspace_name) # Service principal can access directly without delegation service_sdk = as_user(sdk, SERVICE_PRINCIPAL) - result = service_sdk.secrets.access(workspace=workspace_name, name=secret_name) + result = ( + client_from_platform(service_sdk, SecretsClient) + .access_secret(name=secret_name, workspace=workspace_name) + .data() + ) assert result.name == secret_name assert result.value == secret_value @@ -901,16 +926,19 @@ def test_service_can_access_on_behalf_of_service(self, sdk: NeMoPlatform): # Setup: create workspace and secret platform_admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) platform_admin_sdk.workspaces.create(name=workspace_name) - platform_admin_sdk.secrets.create( + client_from_platform(platform_admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), workspace=workspace_name, - name=secret_name, - value=secret_value, - ) + ).data() # Service accesses secret on behalf of another service # Both services have elevated permissions, so this should succeed delegated_sdk = get_sdk_on_behalf_of(as_user(sdk, SERVICE_PRINCIPAL), other_service) - result = delegated_sdk.secrets.access(workspace=workspace_name, name=secret_name) + result = ( + client_from_platform(delegated_sdk, SecretsClient) + .access_secret(name=secret_name, workspace=workspace_name) + .data() + ) assert result.name == secret_name assert result.value == secret_value @@ -932,16 +960,15 @@ def test_editor_cannot_access_on_behalf_of_non_member(self, sdk: NeMoPlatform): principal=editor_email, roles=["Editor"], ) - platform_admin_sdk.secrets.create( + client_from_platform(platform_admin_sdk, SecretsClient).create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), workspace=workspace_name, - name=secret_name, - value=secret_value, - ) + ).data() # Editor tries to access secret on behalf of non-member delegated_sdk = get_sdk_on_behalf_of(as_user(sdk, editor_email), non_member_email) - with pytest.raises(PermissionDeniedError): - delegated_sdk.secrets.access(workspace=workspace_name, name=secret_name) + with pytest.raises(ClientPermissionDeniedError): + client_from_platform(delegated_sdk, SecretsClient).access_secret(name=secret_name, workspace=workspace_name) @pytest.mark.integration @@ -965,15 +992,15 @@ def test_create_secret_with_uppercase_returns_422(self, sdk: NeMoPlatform): """ admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) # Name with uppercase letter - violates DNS-compliant naming rules invalid_name = "test-secret-123-Test" - with pytest.raises(UnprocessableEntityError) as exc_info: - admin_sdk.secrets.create( + with pytest.raises(ClientUnprocessableEntityError) as exc_info: + admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")), workspace="default", - name=invalid_name, - value="test-value", ) # Verify the error message mentions the name validation issue @@ -986,13 +1013,13 @@ def test_create_secret_with_all_uppercase_returns_422(self, sdk: NeMoPlatform): """Test that creating a secret with all uppercase letters returns 422.""" admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) invalid_name = "TEST-SECRET" - with pytest.raises(UnprocessableEntityError) as exc_info: - admin_sdk.secrets.create( + with pytest.raises(ClientUnprocessableEntityError) as exc_info: + admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=invalid_name, value=SecretStr("test-value")), workspace="default", - name=invalid_name, - value="test-value", ) error_message = str(exc_info.value) @@ -1001,14 +1028,14 @@ def test_create_secret_with_all_uppercase_returns_422(self, sdk: NeMoPlatform): def test_create_secret_with_valid_name_succeeds(self, sdk: NeMoPlatform): """Test that creating a secret with a valid DNS-compliant name works.""" admin_sdk = as_user(sdk, TEST_ADMIN_EMAIL) + admin_secrets = client_from_platform(admin_sdk, SecretsClient) # Valid DNS-compliant name (lowercase, digits, hyphens only) valid_name = short_unique_name("valid-secret") - secret = admin_sdk.secrets.create( + secret = admin_secrets.create_secret( + body=PlatformSecretCreateRequest(name=valid_name, value=SecretStr("test-value")), workspace="default", - name=valid_name, - value="test-value", - ) + ).data() assert secret.name == valid_name diff --git a/services/core/secrets/tests/integration/test_secrets_workspace_validation.py b/services/core/secrets/tests/integration/test_secrets_workspace_validation.py index dfe1ab447c..2b41b58361 100644 --- a/services/core/secrets/tests/integration/test_secrets_workspace_validation.py +++ b/services/core/secrets/tests/integration/test_secrets_workspace_validation.py @@ -8,21 +8,22 @@ """ import pytest -from nemo_platform import NeMoPlatform, UnprocessableEntityError +from nemo_platform_plugin.client.errors import UnprocessableEntityError +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest @pytest.mark.integration class TestSecretWorkspaceValidation: """Verify that secrets cannot be created in non-existent workspaces.""" - def test_create_secret_in_nonexistent_workspace_fails(self, sdk: NeMoPlatform): + def test_create_secret_in_nonexistent_workspace_fails(self, sdk: SecretsClient): """Creating a secret in a workspace that doesn't exist should return 422.""" workspace = "nonexistent-workspace" with pytest.raises(UnprocessableEntityError) as exc_info: - sdk.secrets.create( + sdk.create_secret( workspace=workspace, - name="my-secret", - value="my-secret-value", + body=PlatformSecretCreateRequest(name="my-secret", value="my-secret-value"), ) assert exc_info.value.status_code == 422 - assert f"Workspace '{workspace}' does not exist" in exc_info.value.message + assert f"Workspace '{workspace}' does not exist" in exc_info.value.detail diff --git a/services/core/secrets/tests/test_secrets_entities.py b/services/core/secrets/tests/test_secrets_entities.py index bd14402c4e..43b7ce834c 100644 --- a/services/core/secrets/tests/test_secrets_entities.py +++ b/services/core/secrets/tests/test_secrets_entities.py @@ -2,6 +2,9 @@ # SPDX-License-Identifier: Apache-2.0 import pytest +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from nmp.common.secrets.encryption import ( SecretKeyEncryptor, SecretKeyEncryptorConfig, @@ -9,6 +12,7 @@ envelope_encrypt, ) from nmp.core.secrets.entities import PlatformSecret +from pydantic import SecretStr async def test_list_secrets_entities_empty(client_context): @@ -46,28 +50,32 @@ async def test_access_old_secret_with_old_provider_can_be_accessed( assert created_secret.name == secret_name assert created_secret.id is not None + secrets = client_from_platform(client_context.sdk, SecretsClient) + # Retrieve the secret through the API and validate that it can be decrypted correctly - retrieved_secret = client_context.sdk.secrets.retrieve(secret_name, workspace="default") + retrieved_secret = secrets.get_secret(name=secret_name, workspace="default").data() assert retrieved_secret.name == secret_name # Access the secret value and verify decryption - accessed_secret = client_context.sdk.secrets.access(secret_name, workspace="default") + accessed_secret = secrets.access_secret(name=secret_name, workspace="default").data() assert accessed_secret.name == secret_name assert accessed_secret.value == secret_value # Now create a new secret with the current provider to ensure both can coexist new_secret_name = "entity-client-new-secret" new_secret_value = "newsupersecret" - new_created_secret = client_context.sdk.secrets.create( - name=new_secret_name, - value=new_secret_value, + new_created_secret = secrets.create_secret( + body=PlatformSecretCreateRequest( + name=new_secret_name, + value=SecretStr(new_secret_value), + description="New secret with current provider", + ), workspace="default", - description="New secret with current provider", - ) + ).data() assert new_created_secret.name == new_secret_name # Access the new secret and verify decryption - new_accessed_secret = client_context.sdk.secrets.access(new_secret_name, workspace="default") + new_accessed_secret = secrets.access_secret(name=new_secret_name, workspace="default").data() assert new_accessed_secret.name == new_secret_name assert new_accessed_secret.value == new_secret_value diff --git a/services/core/secrets/tests/test_secrets_sdk.py b/services/core/secrets/tests/test_secrets_sdk.py index 8234b8583f..09c7c9e272 100644 --- a/services/core/secrets/tests/test_secrets_sdk.py +++ b/services/core/secrets/tests/test_secrets_sdk.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Integration tests for the secrets service. +"""Integration tests for the secrets service via the typed SecretsClient. These tests verify: - Secret CRUD operations (create, retrieve, list, update, delete) @@ -13,8 +13,9 @@ import uuid -from fastapi import status -from nemo_platform import APIStatusError, NeMoPlatform +from nemo_platform_plugin.client.errors import NotFoundError, UnprocessableEntityError +from nemo_platform_plugin.secrets.client import SecretsClient +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest, PlatformSecretUpdateRequest from nmp.common.entities import DEFAULT_WORKSPACE @@ -24,34 +25,38 @@ def short_secret_name(prefix: str) -> str: return f"{prefix[:22]}-{suffix}" -def test_create_secret(sdk: NeMoPlatform): +def test_create_secret(sdk: SecretsClient): secret_name = short_secret_name("testsecret") secret_value = "supersecret" - secret = sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_name, value=secret_value) + secret = sdk.create_secret( + workspace=DEFAULT_WORKSPACE, body=PlatformSecretCreateRequest(name=secret_name, value=secret_value) + ).data() assert secret.name == secret_name # Retrieve the secret - secret_retrieved = sdk.secrets.retrieve(secret_name, workspace=DEFAULT_WORKSPACE) + secret_retrieved = sdk.get_secret(name=secret_name, workspace=DEFAULT_WORKSPACE).data() assert secret_retrieved.name == secret.name # Access the secret value - secret_access_resp = sdk.secrets.access(secret_name, workspace=DEFAULT_WORKSPACE) + secret_access_resp = sdk.access_secret(name=secret_name, workspace=DEFAULT_WORKSPACE).data() assert secret_access_resp.value == secret_value -def test_create_and_list_secrets(sdk: NeMoPlatform): +def test_create_and_list_secrets(sdk: SecretsClient): secret_name_1 = short_secret_name("secret1") secret_name_2 = short_secret_name("secret2") secret_data = "somedata" - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_name_1, value=secret_data) - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_name_2, value=secret_data) - # List secrets and verify order - # Get first secret first - list_resp = sdk.secrets.list(workspace=DEFAULT_WORKSPACE) # sort="created_at" - secret_names = [secret_ref.name for secret_ref in list_resp.data] + sdk.create_secret( + workspace=DEFAULT_WORKSPACE, body=PlatformSecretCreateRequest(name=secret_name_1, value=secret_data) + ) + sdk.create_secret( + workspace=DEFAULT_WORKSPACE, body=PlatformSecretCreateRequest(name=secret_name_2, value=secret_data) + ) + # List secrets and verify both are present + secret_names = [secret.name for secret in sdk.list_secrets(workspace=DEFAULT_WORKSPACE).items()] assert secret_name_1 in secret_names assert secret_name_2 in secret_names -def test_create_and_list_secrets_with_pagination(sdk: NeMoPlatform): +def test_create_and_list_secrets_with_pagination(sdk: SecretsClient): """Test listing secrets with pagination across multiple pages.""" num_secrets = 25 secret_data = "paginationtest" @@ -60,103 +65,130 @@ def test_create_and_list_secrets_with_pagination(sdk: NeMoPlatform): created_secret_names = [] for i in range(num_secrets): secret_name = short_secret_name(f"page{i:02d}") - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_name, value=secret_data) + sdk.create_secret( + workspace=DEFAULT_WORKSPACE, body=PlatformSecretCreateRequest(name=secret_name, value=secret_data) + ) created_secret_names.append(secret_name) - # Now we should be able to list them with pagination and an iterator - for secret in sdk.secrets.list(workspace=DEFAULT_WORKSPACE): + # The paginated response iterates across all pages transparently. + for secret in sdk.list_secrets(workspace=DEFAULT_WORKSPACE).items(): if secret.name in created_secret_names: created_secret_names.remove(secret.name) assert len(created_secret_names) == 0, "Not all created secrets were found in the list" -def test_create_secret_with_empty_data(sdk: NeMoPlatform): +def test_create_secret_with_empty_data(sdk: SecretsClient): secret_name = short_secret_name("emptydata") + # An empty value is rejected client-side by the request model validator. try: - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_name, value="") - assert False, "Expected an error when creating a secret with empty data" - except APIStatusError as e: - assert e.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + PlatformSecretCreateRequest(name=secret_name, value="") + assert False, "Expected a validation error when creating a secret with empty data" + except ValueError: + pass -def test_create_and_delete_secret(sdk: NeMoPlatform): +def test_create_secret_with_empty_data_server_side(sdk: SecretsClient): + """If an empty value reaches the server, it responds 422.""" + secret_name = short_secret_name("emptydata") + body = PlatformSecretCreateRequest.model_construct(name=secret_name, value=_EmptySecret()) + try: + sdk.create_secret(workspace=DEFAULT_WORKSPACE, body=body) + assert False, "Expected a 422 when creating a secret with empty data" + except UnprocessableEntityError as e: + assert e.status_code == 422 + + +class _EmptySecret: + """Stand-in that serializes to an empty string to exercise server validation.""" + + def get_secret_value(self) -> str: + return "" + + +def test_create_and_delete_secret(sdk: SecretsClient): secret_name = short_secret_name("secret1") secret_value = "deletesecret" - create_resp = sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_name, value=secret_value) + create_resp = sdk.create_secret( + workspace=DEFAULT_WORKSPACE, body=PlatformSecretCreateRequest(name=secret_name, value=secret_value) + ).data() assert secret_name == create_resp.name - sdk.secrets.delete(secret_name, workspace=DEFAULT_WORKSPACE) + sdk.delete_secret(name=secret_name, workspace=DEFAULT_WORKSPACE) try: - sdk.secrets.retrieve(secret_name, workspace=DEFAULT_WORKSPACE) + sdk.get_secret(name=secret_name, workspace=DEFAULT_WORKSPACE) assert False, "Expected an error when retrieving a deleted secret" - except APIStatusError as e: - assert e.status_code == status.HTTP_404_NOT_FOUND + except NotFoundError as e: + assert e.status_code == 404 -def test_update_secret(sdk: NeMoPlatform): +def test_update_secret(sdk: SecretsClient): secret_name = short_secret_name("update") secret_value = "initialvalue" - create_resp = sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_name, value=secret_value) + create_resp = sdk.create_secret( + workspace=DEFAULT_WORKSPACE, body=PlatformSecretCreateRequest(name=secret_name, value=secret_value) + ).data() assert secret_name == create_resp.name assert create_resp.description is None - # Update the secret - updated_secret = sdk.secrets.update(secret_name, workspace=DEFAULT_WORKSPACE, description="Updated description") + # Update the secret's description + updated_secret = sdk.update_secret( + name=secret_name, + workspace=DEFAULT_WORKSPACE, + body=PlatformSecretUpdateRequest(description="Updated description"), + ).data() assert updated_secret.description == "Updated description" - updated_secret = sdk.secrets.update(secret_name, workspace=DEFAULT_WORKSPACE, description="", value="newvalue") + # Update description and value together + updated_secret = sdk.update_secret( + name=secret_name, + workspace=DEFAULT_WORKSPACE, + body=PlatformSecretUpdateRequest(description="", value="newvalue"), + ).data() assert updated_secret.description == "" assert updated_secret.name == secret_name # Access the updated secret value - secret_access_resp = sdk.secrets.access(secret_name, workspace=DEFAULT_WORKSPACE) + secret_access_resp = sdk.access_secret(name=secret_name, workspace=DEFAULT_WORKSPACE).data() assert secret_access_resp.value == "newvalue" -def test_rotate_encryption_keys(sdk: NeMoPlatform): - """Test that secret rotation via SDK preserves secret data. +def test_rotate_encryption_keys(sdk: SecretsClient): + """Test that secret rotation via the client preserves secret data. This test verifies: - 1. Secrets can be created with the SDK - 2. The rotate_encryption_keys admin endpoint can be called via SDK + 1. Secrets can be created with the client + 2. The rotate_encryption_keys admin endpoint can be called via the client 3. After rotation, all secrets remain accessible with their original values """ - # Create multiple secrets across workspaces secrets_data = [ (short_secret_name("rotate1"), "secret-value-1"), (short_secret_name("rotate2"), "secret-value-2"), (short_secret_name("rotate3"), "another-secret-value"), ] - # Create secrets for secret_name, secret_value in secrets_data: - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_name, value=secret_value) + sdk.create_secret( + workspace=DEFAULT_WORKSPACE, body=PlatformSecretCreateRequest(name=secret_name, value=secret_value) + ) - # Verify secrets are accessible before rotation for secret_name, expected_value in secrets_data: - access_resp = sdk.secrets.access(secret_name, workspace=DEFAULT_WORKSPACE) + access_resp = sdk.access_secret(name=secret_name, workspace=DEFAULT_WORKSPACE).data() assert access_resp.value == expected_value # Rotate encryption keys - sdk.secrets.admin.rotate_encryption_keys() + sdk.rotate_encryption_keys() - # Verify all secrets are still accessible with original values after rotation for secret_name, expected_value in secrets_data: - access_resp = sdk.secrets.access(secret_name, workspace=DEFAULT_WORKSPACE) + access_resp = sdk.access_secret(name=secret_name, workspace=DEFAULT_WORKSPACE).data() assert access_resp.value == expected_value, f"Secret {secret_name} value changed after rotation" -def test_rotate_encryption_keys_idempotent(sdk: NeMoPlatform): - """Test that calling rotate_encryption_keys multiple times is safe. - - This test verifies that rotation is idempotent - calling it multiple - times should not corrupt data or cause errors. - """ - # Create a secret +def test_rotate_encryption_keys_idempotent(sdk: SecretsClient): + """Test that calling rotate_encryption_keys multiple times is safe.""" secret_name = short_secret_name("idempotent") secret_value = "idempotent-test-value" - sdk.secrets.create(workspace=DEFAULT_WORKSPACE, name=secret_name, value=secret_value) + sdk.create_secret( + workspace=DEFAULT_WORKSPACE, body=PlatformSecretCreateRequest(name=secret_name, value=secret_value) + ) - # Rotate multiple times for _ in range(3): - sdk.secrets.admin.rotate_encryption_keys() + sdk.rotate_encryption_keys() - # Secret should still be accessible with original value - access_resp = sdk.secrets.access(secret_name, workspace=DEFAULT_WORKSPACE) + access_resp = sdk.access_secret(name=secret_name, workspace=DEFAULT_WORKSPACE).data() assert access_resp.value == secret_value diff --git a/tests/agentic-use/data-designer-config-cli-easy/tests/test_outputs.py b/tests/agentic-use/data-designer-config-cli-easy/tests/test_outputs.py index 387957c694..1251be5fff 100644 --- a/tests/agentic-use/data-designer-config-cli-easy/tests/test_outputs.py +++ b/tests/agentic-use/data-designer-config-cli-easy/tests/test_outputs.py @@ -12,6 +12,8 @@ import os from nemo_platform import NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.secrets.client import SecretsClient def test_secret_created() -> None: @@ -20,8 +22,8 @@ def test_secret_created() -> None: client = NeMoPlatform(base_url=nmp_base_url, workspace="default") # List secrets and check for our test secret - response = client.secrets.list() - secret_names = [s.name for s in response.data] + secrets = client_from_platform(client, SecretsClient) + secret_names = [s.name for s in secrets.list_secrets().items()] assert "dd-test-api-key" in secret_names, f"Secret 'dd-test-api-key' was not created! Found secrets: {secret_names}" print("Test passed: dd-test-api-key secret was successfully created") diff --git a/tests/agentic-use/data-designer-config-cli/tests/test_outputs.py b/tests/agentic-use/data-designer-config-cli/tests/test_outputs.py index 387957c694..1251be5fff 100644 --- a/tests/agentic-use/data-designer-config-cli/tests/test_outputs.py +++ b/tests/agentic-use/data-designer-config-cli/tests/test_outputs.py @@ -12,6 +12,8 @@ import os from nemo_platform import NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.secrets.client import SecretsClient def test_secret_created() -> None: @@ -20,8 +22,8 @@ def test_secret_created() -> None: client = NeMoPlatform(base_url=nmp_base_url, workspace="default") # List secrets and check for our test secret - response = client.secrets.list() - secret_names = [s.name for s in response.data] + secrets = client_from_platform(client, SecretsClient) + secret_names = [s.name for s in secrets.list_secrets().items()] assert "dd-test-api-key" in secret_names, f"Secret 'dd-test-api-key' was not created! Found secrets: {secret_names}" print("Test passed: dd-test-api-key secret was successfully created") diff --git a/tests/agentic-use/inference-provider-reg-cli-easy/tests/test_outputs.py b/tests/agentic-use/inference-provider-reg-cli-easy/tests/test_outputs.py index 4bd8ab55e5..66e1e9d90d 100644 --- a/tests/agentic-use/inference-provider-reg-cli-easy/tests/test_outputs.py +++ b/tests/agentic-use/inference-provider-reg-cli-easy/tests/test_outputs.py @@ -16,6 +16,8 @@ import pytest from nemo_platform import NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.secrets.client import SecretsClient from trace_reader import get_session WORKSPACE = "default" @@ -32,7 +34,8 @@ def client() -> NeMoPlatform: def test_api_key_secret_exists(client: NeMoPlatform) -> None: """Test that the API key secret was created for provider registration.""" - response = client.secrets.retrieve(name="harbor-provider-api-key") + secrets = client_from_platform(client, SecretsClient) + response = secrets.get_secret(name="harbor-provider-api-key").data() assert response.name == "harbor-provider-api-key", ( f"Expected secret name 'harbor-provider-api-key', got '{response.name}'" ) diff --git a/tests/agentic-use/inference-provider-reg-cli/tests/test_outputs.py b/tests/agentic-use/inference-provider-reg-cli/tests/test_outputs.py index 4bd8ab55e5..66e1e9d90d 100644 --- a/tests/agentic-use/inference-provider-reg-cli/tests/test_outputs.py +++ b/tests/agentic-use/inference-provider-reg-cli/tests/test_outputs.py @@ -16,6 +16,8 @@ import pytest from nemo_platform import NeMoPlatform +from nemo_platform_plugin.client.adapter import client_from_platform +from nemo_platform_plugin.secrets.client import SecretsClient from trace_reader import get_session WORKSPACE = "default" @@ -32,7 +34,8 @@ def client() -> NeMoPlatform: def test_api_key_secret_exists(client: NeMoPlatform) -> None: """Test that the API key secret was created for provider registration.""" - response = client.secrets.retrieve(name="harbor-provider-api-key") + secrets = client_from_platform(client, SecretsClient) + response = secrets.get_secret(name="harbor-provider-api-key").data() assert response.name == "harbor-provider-api-key", ( f"Expected secret name 'harbor-provider-api-key', got '{response.name}'" ) diff --git a/tests/agentic-use/secrets-crud-cli-easy/tests/test_outputs.py b/tests/agentic-use/secrets-crud-cli-easy/tests/test_outputs.py index 19915cb52f..d12e490c1e 100644 --- a/tests/agentic-use/secrets-crud-cli-easy/tests/test_outputs.py +++ b/tests/agentic-use/secrets-crud-cli-easy/tests/test_outputs.py @@ -13,40 +13,39 @@ import os import pytest -from nemo_platform import NeMoPlatform +from nemo_platform_plugin.secrets.client import SecretsClient WORKSPACE = "default" @pytest.fixture -def client() -> NeMoPlatform: +def client() -> SecretsClient: nmp_base_url = os.environ.get("NMP_BASE_URL", "http://localhost:8080") - return NeMoPlatform(base_url=nmp_base_url, workspace=WORKSPACE) + return SecretsClient(base_url=nmp_base_url, workspace=WORKSPACE) -def test_harbor_test_secret_deleted(client: NeMoPlatform) -> None: +def test_harbor_test_secret_deleted(client: SecretsClient) -> None: """Test that harbor-test-secret was deleted after CRUD operations.""" - response = client.secrets.list() - secret_names = [s.name for s in response.data] + secret_names = [s.name for s in client.list_secrets().items()] assert "harbor-test-secret" not in secret_names, ( f"Secret 'harbor-test-secret' should have been deleted but still exists! Found: {secret_names}" ) -def test_harbor_final_secret_exists(client: NeMoPlatform) -> None: +def test_harbor_final_secret_exists(client: SecretsClient) -> None: """Test that harbor-final-secret was created and has correct metadata.""" - response = client.secrets.retrieve(name="harbor-final-secret") + response = client.get_secret(name="harbor-final-secret").data() assert response.name == "harbor-final-secret", f"Expected secret name 'harbor-final-secret', got '{response.name}'" assert response.description == "Final secret for verification", ( f"Expected description 'Final secret for verification', got '{response.description}'" ) -def test_secret_value_not_exposed(client: NeMoPlatform) -> None: +def test_secret_value_not_exposed(client: SecretsClient) -> None: """Test that secret values are not exposed in list/retrieve responses.""" - response = client.secrets.retrieve(name="harbor-final-secret") + response = client.get_secret(name="harbor-final-secret").data() # PlatformSecretResponse should only contain metadata fields, not the secret data. - # The actual secret value is only available via client.secrets.access(). + # The actual secret value is only available via client.access_secret(). response_dict = response.model_dump() assert "data" not in response_dict or response_dict.get("data") is None, ( "Secret value should not be exposed in retrieve response" diff --git a/tests/agentic-use/secrets-crud-cli/tests/test_outputs.py b/tests/agentic-use/secrets-crud-cli/tests/test_outputs.py index 19915cb52f..d12e490c1e 100644 --- a/tests/agentic-use/secrets-crud-cli/tests/test_outputs.py +++ b/tests/agentic-use/secrets-crud-cli/tests/test_outputs.py @@ -13,40 +13,39 @@ import os import pytest -from nemo_platform import NeMoPlatform +from nemo_platform_plugin.secrets.client import SecretsClient WORKSPACE = "default" @pytest.fixture -def client() -> NeMoPlatform: +def client() -> SecretsClient: nmp_base_url = os.environ.get("NMP_BASE_URL", "http://localhost:8080") - return NeMoPlatform(base_url=nmp_base_url, workspace=WORKSPACE) + return SecretsClient(base_url=nmp_base_url, workspace=WORKSPACE) -def test_harbor_test_secret_deleted(client: NeMoPlatform) -> None: +def test_harbor_test_secret_deleted(client: SecretsClient) -> None: """Test that harbor-test-secret was deleted after CRUD operations.""" - response = client.secrets.list() - secret_names = [s.name for s in response.data] + secret_names = [s.name for s in client.list_secrets().items()] assert "harbor-test-secret" not in secret_names, ( f"Secret 'harbor-test-secret' should have been deleted but still exists! Found: {secret_names}" ) -def test_harbor_final_secret_exists(client: NeMoPlatform) -> None: +def test_harbor_final_secret_exists(client: SecretsClient) -> None: """Test that harbor-final-secret was created and has correct metadata.""" - response = client.secrets.retrieve(name="harbor-final-secret") + response = client.get_secret(name="harbor-final-secret").data() assert response.name == "harbor-final-secret", f"Expected secret name 'harbor-final-secret', got '{response.name}'" assert response.description == "Final secret for verification", ( f"Expected description 'Final secret for verification', got '{response.description}'" ) -def test_secret_value_not_exposed(client: NeMoPlatform) -> None: +def test_secret_value_not_exposed(client: SecretsClient) -> None: """Test that secret values are not exposed in list/retrieve responses.""" - response = client.secrets.retrieve(name="harbor-final-secret") + response = client.get_secret(name="harbor-final-secret").data() # PlatformSecretResponse should only contain metadata fields, not the secret data. - # The actual secret value is only available via client.secrets.access(). + # The actual secret value is only available via client.access_secret(). response_dict = response.model_dump() assert "data" not in response_dict or response_dict.get("data") is None, ( "Secret value should not be exposed in retrieve response" diff --git a/tests/agentic-use/seed_providers.py b/tests/agentic-use/seed_providers.py index e301c96b82..3ff211a70b 100644 --- a/tests/agentic-use/seed_providers.py +++ b/tests/agentic-use/seed_providers.py @@ -149,8 +149,17 @@ def _create_secret(sdk: Any, workspace: str, secret_name: str, secret_value: str For long-lived platform instances a stale secret (e.g. rotated key) would require manual deletion or an update-on-conflict strategy. """ + from nemo_platform_plugin.client.adapter import client_from_platform + from nemo_platform_plugin.secrets.client import SecretsClient + from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest + from pydantic import SecretStr + + secrets = client_from_platform(sdk, SecretsClient) try: - sdk.secrets.create(name=secret_name, value=secret_value, workspace=workspace) + secrets.create_secret( + body=PlatformSecretCreateRequest(name=secret_name, value=SecretStr(secret_value)), + workspace=workspace, + ) logger.info("Created secret '%s'", secret_name) except Exception as exc: if _is_conflict(exc): diff --git a/tests/agentic-use/tests/test_seed_providers.py b/tests/agentic-use/tests/test_seed_providers.py index 95c9691d7e..53185afcd2 100644 --- a/tests/agentic-use/tests/test_seed_providers.py +++ b/tests/agentic-use/tests/test_seed_providers.py @@ -13,6 +13,7 @@ from unittest.mock import MagicMock, patch import pytest +from nemo_platform_plugin.secrets.types import PlatformSecretCreateRequest from seed_providers import ( DEFAULT_HTTP_TIMEOUT_SEC, ProviderSeedResult, @@ -90,19 +91,38 @@ def test_duplicate_from_env_is_valid(self, tmp_path: Path) -> None: class TestCreateSecret: - def test_creates_secret(self) -> None: + @patch("nemo_platform_plugin.client.adapter.client_from_platform") + def test_creates_secret(self, mock_client_from_platform: MagicMock) -> None: sdk = MagicMock() + mock_secrets = MagicMock() + mock_client_from_platform.return_value = mock_secrets + _create_secret(sdk, "ws", "my-secret", "val") - sdk.secrets.create.assert_called_once_with(name="my-secret", value="val", workspace="ws") - def test_ignores_conflict(self) -> None: + mock_secrets.create_secret.assert_called_once() + kwargs = mock_secrets.create_secret.call_args.kwargs + body = kwargs["body"] + assert isinstance(body, PlatformSecretCreateRequest) + assert body.name == "my-secret" + assert body.value.get_secret_value() == "val" + assert kwargs["workspace"] == "ws" + + @patch("nemo_platform_plugin.client.adapter.client_from_platform") + def test_ignores_conflict(self, mock_client_from_platform: MagicMock) -> None: sdk = MagicMock() - sdk.secrets.create.side_effect = Exception("409 Conflict: already exists") + mock_secrets = MagicMock() + mock_secrets.create_secret.side_effect = Exception("409 Conflict: already exists") + mock_client_from_platform.return_value = mock_secrets + _create_secret(sdk, "ws", "my-secret", "val") - def test_raises_on_other_error(self) -> None: + @patch("nemo_platform_plugin.client.adapter.client_from_platform") + def test_raises_on_other_error(self, mock_client_from_platform: MagicMock) -> None: sdk = MagicMock() - sdk.secrets.create.side_effect = RuntimeError("network error") + mock_secrets = MagicMock() + mock_secrets.create_secret.side_effect = RuntimeError("network error") + mock_client_from_platform.return_value = mock_secrets + with pytest.raises(RuntimeError, match="network error"): _create_secret(sdk, "ws", "my-secret", "val") @@ -228,9 +248,14 @@ def test_ignores_409_conflict(self, mock_urlopen: MagicMock) -> None: class TestSeedAll: + @patch("nemo_platform_plugin.client.adapter.client_from_platform") @patch("nemo_platform.NeMoPlatform") def test_skips_unset_env_vars( - self, mock_sdk_cls: MagicMock, manifest_path: Path, monkeypatch: pytest.MonkeyPatch + self, + mock_sdk_cls: MagicMock, + mock_client_from_platform: MagicMock, + manifest_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("BUILD_KEY", raising=False) monkeypatch.delenv("INF_KEY", raising=False) @@ -238,16 +263,22 @@ def test_skips_unset_env_vars( result = seed_all(manifest_path, base_url="http://localhost:8080") assert result.ok assert all(p.status == "skipped" for p in result.providers) - mock_sdk_cls.return_value.secrets.create.assert_not_called() + mock_client_from_platform.return_value.create_secret.assert_not_called() + @patch("nemo_platform_plugin.client.adapter.client_from_platform") @patch("nemo_platform.NeMoPlatform") def test_seeds_providers( - self, mock_sdk_cls: MagicMock, manifest_path: Path, monkeypatch: pytest.MonkeyPatch + self, + mock_sdk_cls: MagicMock, + mock_client_from_platform: MagicMock, + manifest_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("BUILD_KEY", "nvapi-xxx") monkeypatch.setenv("INF_KEY", "sk-yyy") sdk = mock_sdk_cls.return_value + mock_secrets = mock_client_from_platform.return_value provider_obj = MagicMock() provider_obj.served_models = [MagicMock(model_entity_id="ns/m")] sdk.inference.providers.retrieve.return_value = provider_obj @@ -257,12 +288,17 @@ def test_seeds_providers( assert len(result.providers) == 2 assert result.providers[0].status == "ok" assert result.providers[1].status == "ok" - assert sdk.secrets.create.call_count == 2 + assert mock_secrets.create_secret.call_count == 2 assert sdk.inference.providers.create.call_count == 2 + @patch("nemo_platform_plugin.client.adapter.client_from_platform") @patch("nemo_platform.NeMoPlatform") def test_partial_env_skips_missing( - self, mock_sdk_cls: MagicMock, manifest_path: Path, monkeypatch: pytest.MonkeyPatch + self, + mock_sdk_cls: MagicMock, + mock_client_from_platform: MagicMock, + manifest_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("BUILD_KEY", "nvapi-xxx") monkeypatch.delenv("INF_KEY", raising=False) @@ -272,15 +308,19 @@ def test_partial_env_skips_missing( assert result.providers[0].status == "ok" assert result.providers[1].status == "skipped" + @patch("nemo_platform_plugin.client.adapter.client_from_platform") @patch("nemo_platform.NeMoPlatform") def test_error_on_create_marks_status( - self, mock_sdk_cls: MagicMock, manifest_path: Path, monkeypatch: pytest.MonkeyPatch + self, + mock_sdk_cls: MagicMock, + mock_client_from_platform: MagicMock, + manifest_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("BUILD_KEY", "nvapi-xxx") monkeypatch.delenv("INF_KEY", raising=False) - sdk = mock_sdk_cls.return_value - sdk.secrets.create.side_effect = RuntimeError("kaboom") + mock_client_from_platform.return_value.create_secret.side_effect = RuntimeError("kaboom") result = seed_all(manifest_path, base_url="http://localhost:8080") assert not result.ok