From f64335ed8d74d60ed32a2a766c7e4d3a266f4fd0 Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Fri, 26 Jun 2026 14:58:31 -0600 Subject: [PATCH 1/4] fix(models): read model-source from PVC after puller Job deletion The k8s direct-emission reconciler stamps the model source on both the weight-puller Job and the PVC, but _existing_model_source() only read the Job. At P3 the Job is deleted to release the RWO volume, so for an already-serving deployment the Job is gone and the lookup returned None -- causing a later model-revision change to skip the re-pull branch and serve stale weights. Read the source from the Job first, then fall back to the (long-lived) PVC annotation, and propagate non-404 API errors instead of swallowing them. Signed-off-by: Ben McCown --- .../k8s_nim_operator/reconcilers/k8s.py | 36 +++++++- .../test_k8s_nim_operator_backend.py | 82 +++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/services/core/models/src/nmp/core/models/controllers/backends/k8s_nim_operator/reconcilers/k8s.py b/services/core/models/src/nmp/core/models/controllers/backends/k8s_nim_operator/reconcilers/k8s.py index e87e3214b23..744daeff25c 100644 --- a/services/core/models/src/nmp/core/models/controllers/backends/k8s_nim_operator/reconcilers/k8s.py +++ b/services/core/models/src/nmp/core/models/controllers/backends/k8s_nim_operator/reconcilers/k8s.py @@ -718,14 +718,42 @@ def _find_job_pod_name(self, job_name: str) -> str | None: return None def _existing_model_source(self, resource_name: str) -> str | None: - """Read the model-source annotation off the existing puller Job, if any.""" + """Read the recorded model-source for a deployment, from the Job or the PVC. + + The model source is stamped as an annotation on BOTH the puller Job and + the PVC at create time. The Job is the primary source while it exists, but + it is deleted at P3 (to release the RWO volume) once weights are pulled -- + so for an already-serving deployment the Job is gone and we must read the + annotation from the (long-lived) PVC instead. Without the PVC fallback a + later model-revision change would read ``None`` here, skip the re-pull + branch, and serve stale weights. + + Returns ``None`` only when neither object carries the annotation (or + neither exists). Non-404 API errors are propagated rather than swallowed. + """ + # Primary: the puller Job (present during/just after the pull phase). try: job = self._batch_v1.read_namespaced_job( name=vllm_k8s_compiler.pull_job_name(resource_name), namespace=self._k8s_namespace ) - except k8s_client.exceptions.ApiException: - return None - annotations = (job.metadata.annotations or {}) if job.metadata else {} + annotations = (job.metadata.annotations or {}) if job.metadata else {} + source = annotations.get(vllm_k8s_compiler.MODEL_SOURCE_ANNOTATION) + if source: + return source + except k8s_client.exceptions.ApiException as e: + if e.status != 404: + raise + + # Fallback: the PVC (survives Job deletion at P3). + try: + pvc = self._core_v1.read_namespaced_persistent_volume_claim( + name=vllm_k8s_compiler.pvc_name(resource_name), namespace=self._k8s_namespace + ) + except k8s_client.exceptions.ApiException as e: + if e.status == 404: + return None + raise + annotations = (pvc.metadata.annotations or {}) if pvc.metadata else {} return annotations.get(vllm_k8s_compiler.MODEL_SOURCE_ANNOTATION) def _delete_serving_resources(self, resource_name: str) -> list[str]: 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 0b483c7d56c..32b337cfebf 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 @@ -2453,6 +2453,88 @@ async def test_vllm_update_changed_source_repulls(k8s_backend, sample_deployment assert result.status == "PENDING" +@pytest.mark.asyncio +async def test_vllm_update_changed_source_repulls_after_job_deleted(k8s_backend, sample_deployment): + """Changed source is detected via the PVC annotation once the puller Job is gone. + + At P3 the puller Job is deleted, so an already-serving deployment has no Job. + The model source must still be read (from the PVC) so a revision change + re-pulls instead of serving stale weights. + """ + backend = _vllm_backend(k8s_backend) + config = _vllm_config() + + # Puller Job has been deleted (P3 completed). + backend._batch_v1.read_namespaced_job.side_effect = _api_exception(404) + # PVC survives and carries the original model source. + existing_pvc = MagicMock() + existing_pvc.metadata.annotations = {"nmp.nvidia.com/model-source": "default/old-model"} + backend._core_v1.read_namespaced_persistent_volume_claim.return_value = existing_pvc + + with patch.object(backend, "_resolve_model_source", return_value=("default", "qwen", "v2")): + with patch.object(backend._k8s_reconciler, "_delete_serving_resources") as mock_delete: + _sync_reconcilers(backend) + result = await backend.update_model_deployment( + ModelContext(model_deployment=sample_deployment, model_deployment_config=config, model_entity=None) + ) + + # Source change detected from the PVC -> re-pull. + mock_delete.assert_called_once() + backend._core_v1.create_namespaced_persistent_volume_claim.assert_called_once() + backend._batch_v1.create_namespaced_job.assert_called_once() + assert result.status == "PENDING" + + +@pytest.mark.asyncio +async def test_vllm_update_unchanged_source_via_pvc_does_not_repull(k8s_backend, sample_deployment): + """Unchanged source read from the PVC (Job gone) patches in place, no re-pull.""" + backend = _vllm_backend(k8s_backend) + config = _vllm_config() + + backend._batch_v1.read_namespaced_job.side_effect = _api_exception(404) + existing_pvc = MagicMock() + existing_pvc.metadata.annotations = {"nmp.nvidia.com/model-source": "default/qwen"} + backend._core_v1.read_namespaced_persistent_volume_claim.return_value = existing_pvc + # Serving Deployment exists -> patch path. + backend._apps_v1.read_namespaced_deployment.return_value = MagicMock() + + with patch.object(backend, "_resolve_model_source", return_value=("default", "qwen", None)): + with patch.object(backend._k8s_reconciler, "_delete_serving_resources") as mock_delete: + _sync_reconcilers(backend) + result = await backend.update_model_deployment( + ModelContext(model_deployment=sample_deployment, model_deployment_config=config, model_entity=None) + ) + + mock_delete.assert_not_called() + backend._batch_v1.create_namespaced_job.assert_not_called() + backend._apps_v1.patch_namespaced_deployment.assert_called_once() + assert result.status == "PENDING" + + +@pytest.mark.asyncio +async def test_existing_model_source_reads_pvc_when_job_absent(k8s_backend): + """_existing_model_source falls back to the PVC annotation when the Job is gone.""" + backend = _vllm_backend(k8s_backend) + backend._batch_v1.read_namespaced_job.side_effect = _api_exception(404) + pvc = MagicMock() + pvc.metadata.annotations = {"nmp.nvidia.com/model-source": "default/qwen@v3"} + backend._core_v1.read_namespaced_persistent_volume_claim.return_value = pvc + + _sync_reconcilers(backend) + assert backend._k8s_reconciler._existing_model_source("md-default-qwen") == "default/qwen@v3" + + +@pytest.mark.asyncio +async def test_existing_model_source_none_when_job_and_pvc_absent(k8s_backend): + """_existing_model_source returns None when neither Job nor PVC exists.""" + backend = _vllm_backend(k8s_backend) + backend._batch_v1.read_namespaced_job.side_effect = _api_exception(404) + backend._core_v1.read_namespaced_persistent_volume_claim.side_effect = _api_exception(404) + + _sync_reconcilers(backend) + assert backend._k8s_reconciler._existing_model_source("md-default-qwen") is None + + @pytest.mark.asyncio async def test_vllm_update_during_pull_is_noop(k8s_backend, sample_deployment): """Unchanged source, serving Deployment not yet created but puller Job present. From ce91364ecd5b098b654e81643c963a6310f7d442 Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Fri, 26 Jun 2026 15:05:55 -0600 Subject: [PATCH 2/4] feat(models): configurable run_as_user/run_as_group for deployments Add executor_config.run_as_user / run_as_group to override the serving pod's securityContext uid/gid on the k8s backend. An explicit value wins over the engine default (vLLM pins its image's 2000/0; generic runs as the image's own user). Applied to both the serving container and the weight-puller Job so pulled weights are owned by the same user the server reads them as. Ignored by the docker backend, which does not set a container user. Regenerates the OpenAPI spec for the new fields. (SDK regeneration via the Stainless flow is a separate maintainer/CI step.) Signed-off-by: Ben McCown --- openapi/ga/individual/platform.openapi.yaml | 15 +++++ openapi/ga/openapi.yaml | 15 +++++ openapi/openapi.yaml | 15 +++++ .../models/controllers/backends/common.py | 4 ++ .../k8s_nim_operator/reconcilers/k8s.py | 28 ++++++--- .../models/src/nmp/core/models/schemas.py | 19 ++++++ .../test_k8s_nim_operator_backend.py | 62 ++++++++++++++++++- 7 files changed, 147 insertions(+), 11 deletions(-) diff --git a/openapi/ga/individual/platform.openapi.yaml b/openapi/ga/individual/platform.openapi.yaml index 361a63db74e..db6ea52a4ec 100644 --- a/openapi/ga/individual/platform.openapi.yaml +++ b/openapi/ga/individual/platform.openapi.yaml @@ -8856,6 +8856,21 @@ components: that expose a non-standard health endpoint. type: string maxLength: 255 + run_as_user: + title: Run As User + description: Pod securityContext runAsUser (uid) for the serving container + (k8s backend only). If unset, the engine default applies (vLLM pins its + image's user; generic uses the image's own user). Ignored by the docker + backend. + type: integer + minimum: 0.0 + run_as_group: + title: Run As Group + description: Pod securityContext runAsGroup (gid) for the serving container + (k8s backend only). If unset, the engine default applies. Ignored by the + docker backend. + type: integer + minimum: 0.0 additional_envs: title: Additional Envs description: Additional environment variables for the deployment diff --git a/openapi/ga/openapi.yaml b/openapi/ga/openapi.yaml index 361a63db74e..db6ea52a4ec 100644 --- a/openapi/ga/openapi.yaml +++ b/openapi/ga/openapi.yaml @@ -8856,6 +8856,21 @@ components: that expose a non-standard health endpoint. type: string maxLength: 255 + run_as_user: + title: Run As User + description: Pod securityContext runAsUser (uid) for the serving container + (k8s backend only). If unset, the engine default applies (vLLM pins its + image's user; generic uses the image's own user). Ignored by the docker + backend. + type: integer + minimum: 0.0 + run_as_group: + title: Run As Group + description: Pod securityContext runAsGroup (gid) for the serving container + (k8s backend only). If unset, the engine default applies. Ignored by the + docker backend. + type: integer + minimum: 0.0 additional_envs: title: Additional Envs description: Additional environment variables for the deployment diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 361a63db74e..db6ea52a4ec 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -8856,6 +8856,21 @@ components: that expose a non-standard health endpoint. type: string maxLength: 255 + run_as_user: + title: Run As User + description: Pod securityContext runAsUser (uid) for the serving container + (k8s backend only). If unset, the engine default applies (vLLM pins its + image's user; generic uses the image's own user). Ignored by the docker + backend. + type: integer + minimum: 0.0 + run_as_group: + title: Run As Group + description: Pod securityContext runAsGroup (gid) for the serving container + (k8s backend only). If unset, the engine default applies. Ignored by the + docker backend. + type: integer + minimum: 0.0 additional_envs: title: Additional Envs description: Additional environment variables for the deployment diff --git a/services/core/models/src/nmp/core/models/controllers/backends/common.py b/services/core/models/src/nmp/core/models/controllers/backends/common.py index 8cd0a98e22e..97b4affb436 100644 --- a/services/core/models/src/nmp/core/models/controllers/backends/common.py +++ b/services/core/models/src/nmp/core/models/controllers/backends/common.py @@ -55,6 +55,8 @@ class DeploymentConfigView: image_name: Optional[str] = None image_tag: Optional[str] = None health_check_path: Optional[str] = None + run_as_user: Optional[int] = None + run_as_group: Optional[int] = None additional_envs: Optional[Dict[str, str]] = None additional_args: Optional[List[str]] = None k8s_nim_operator_config: Optional[K8sNIMOperatorConfig] = None @@ -78,6 +80,8 @@ def deployment_config_view(config: Optional[_DeploymentConfigLike]) -> Deploymen image_name=getattr(executor, "image_name", None), image_tag=getattr(executor, "image_tag", None), health_check_path=getattr(executor, "health_check_path", None), + run_as_user=getattr(executor, "run_as_user", None), + run_as_group=getattr(executor, "run_as_group", None), additional_envs=getattr(executor, "additional_envs", None), additional_args=getattr(executor, "additional_args", None), k8s_nim_operator_config=getattr(executor, "k8s_nim_operator_config", None), diff --git a/services/core/models/src/nmp/core/models/controllers/backends/k8s_nim_operator/reconcilers/k8s.py b/services/core/models/src/nmp/core/models/controllers/backends/k8s_nim_operator/reconcilers/k8s.py index 744daeff25c..80de3669a58 100644 --- a/services/core/models/src/nmp/core/models/controllers/backends/k8s_nim_operator/reconcilers/k8s.py +++ b/services/core/models/src/nmp/core/models/controllers/backends/k8s_nim_operator/reconcilers/k8s.py @@ -37,6 +37,7 @@ from nmp.core.models.app.constants import MODEL_MANAGED_BY_LABEL, MODEL_MANAGED_BY_MODELS_CONTROLLER from nmp.core.models.controllers.backends import generic_compiler, vllm_compiler from nmp.core.models.controllers.backends.backends import DeploymentStatusUpdate +from nmp.core.models.controllers.backends.common import DeploymentConfigView from nmp.core.models.controllers.backends.engine import ( ENGINE_GENERIC, ENGINE_VLLM, @@ -137,7 +138,7 @@ async def create(self, resolved: ResolvedDeployment) -> DeploymentStatusUpdate: if resolved.files_hf_url is None: raise ValueError(f"Cannot create {engine} deployment: Files HF endpoint was not resolved") - user_id, group_id = self._pod_user(engine) + user_id, group_id = self._pod_user(engine, view) pvc = vllm_k8s_compiler.compile_pvc( resource_name=resource_name, workspace=deployment.workspace, @@ -449,16 +450,25 @@ def _project_deployment_readiness(self, resource_name: str) -> DeploymentStatusU # Engine-parameterized serving objects (shared by vLLM + generic) # ------------------------------------------------------------------ - def _pod_user(self, engine: str) -> tuple[Optional[int], Optional[int]]: - """Pod securityContext uid/gid for an engine. + def _pod_user(self, engine: str, view: DeploymentConfigView) -> tuple[Optional[int], Optional[int]]: + """Pod securityContext uid/gid for a deployment. - vLLM pins its image's user (2000/0); a generic container runs as its own - image's user (unset), since we can't assume an arbitrary image tolerates a - forced uid/gid. + An explicit ``executor_config.run_as_user`` / ``run_as_group`` always wins + (each independently). Otherwise the engine default applies: vLLM pins its + image's user (2000/0); a generic container runs as its own image's user + (unset), since we can't assume an arbitrary image tolerates a forced + uid/gid. """ if engine == ENGINE_VLLM: - return self._backend_config.default_vllm_user_id, self._backend_config.default_vllm_group_id - return None, None + default_user: Optional[int] = self._backend_config.default_vllm_user_id + default_group: Optional[int] = self._backend_config.default_vllm_group_id + else: + default_user = None + default_group = None + + user_id = view.run_as_user if view.run_as_user is not None else default_user + group_id = view.run_as_group if view.run_as_group is not None else default_group + return user_id, group_id def _serving_spec( self, @@ -478,7 +488,7 @@ def _serving_spec( resource_name = resolved.resource_name health_path = resolve_health_path(engine, view) startup_grace = self._backend_config.default_startup_probe_grace_period_seconds or 600 - user_id, group_id = self._pod_user(engine) + user_id, group_id = self._pod_user(engine, view) if engine == ENGINE_GENERIC: image_name, image_tag = generic_compiler.resolve_generic_image(view) diff --git a/services/core/models/src/nmp/core/models/schemas.py b/services/core/models/src/nmp/core/models/schemas.py index 65dfb4ec6d3..b3ba68a8f93 100644 --- a/services/core/models/src/nmp/core/models/schemas.py +++ b/services/core/models/src/nmp/core/models/schemas.py @@ -1421,6 +1421,25 @@ class ContainerExecutorConfig(BaseModel): max_length=constants.MAX_LENGTH_255, ) + # Pod securityContext user override (k8s backend only). When unset, the engine + # picks an appropriate default (vLLM pins its image's user; generic runs as the + # image's own user). Set these to run an arbitrary container as a specific + # uid/gid -- e.g. a generic image that requires a particular user. Ignored by + # the docker backend, which does not set a container user. + run_as_user: Optional[int] = Field( + default=None, + ge=0, + description="Pod securityContext runAsUser (uid) for the serving container (k8s backend only). " + "If unset, the engine default applies (vLLM pins its image's user; generic uses the image's " + "own user). Ignored by the docker backend.", + ) + run_as_group: Optional[int] = Field( + default=None, + ge=0, + description="Pod securityContext runAsGroup (gid) for the serving container (k8s backend only). " + "If unset, the engine default applies. Ignored by the docker backend.", + ) + # Escape hatches -- for anything not surfaced as a first-class field above additional_envs: Optional[Dict[str, str]] = Field( default=None, description="Additional environment variables for the deployment" 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 32b337cfebf..0998fbf0e87 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 @@ -1934,7 +1934,7 @@ async def test_delete_model_deployment_by_id_calls_delete_resources(mock_nmp_sdk # =========================================================================== -def _vllm_config(*, gpu: int = 1, lora_enabled: bool = False): +def _vllm_config(*, gpu: int = 1, lora_enabled: bool = False, run_as_user=None, run_as_group=None): """A minimal vLLM ModelDeploymentConfig-like object for dispatch/compile.""" return SimpleNamespace( engine="vllm", @@ -1953,6 +1953,8 @@ def _vllm_config(*, gpu: int = 1, lora_enabled: bool = False): image_name=None, image_tag=None, health_check_path=None, + run_as_user=run_as_user, + run_as_group=run_as_group, additional_envs=None, additional_args=[], k8s_nim_operator_config=None, @@ -2000,7 +2002,14 @@ async def test_vllm_create_emits_pvc_and_job_only(k8s_backend, sample_deployment assert job.spec.template.spec.containers[0].resources.requests["nvidia.com/gpu"] == "2" -def _generic_config(*, gpu: int = 0, image="nvcr.io/nim/nvidia/nemoguard-jailbreak-detect", tag="1.10.1"): +def _generic_config( + *, + gpu: int = 0, + image="nvcr.io/nim/nvidia/nemoguard-jailbreak-detect", + tag="1.10.1", + run_as_user=None, + run_as_group=None, +): """A minimal generic ModelDeploymentConfig-like object (no model weights).""" return SimpleNamespace( engine="generic", @@ -2019,6 +2028,8 @@ def _generic_config(*, gpu: int = 0, image="nvcr.io/nim/nvidia/nemoguard-jailbre image_name=image, image_tag=tag, health_check_path="/v1/health/ready", + run_as_user=run_as_user, + run_as_group=run_as_group, additional_envs={"FOO": "bar"}, additional_args=["--port", "8000"], k8s_nim_operator_config=None, @@ -2063,6 +2074,53 @@ async def test_generic_create_emits_deployment_and_service_no_pvc(k8s_backend, s assert "model-store" not in mount_names # Readiness/startup probes use the explicit health_check_path. assert container.readiness_probe.http_get.path == "/v1/health/ready" + # No uid/gid override -> generic runs as the image's own user (no securityContext). + assert dep_obj.spec.template.spec.security_context is None + + +@pytest.mark.asyncio +async def test_generic_create_applies_run_as_user_override(k8s_backend, sample_deployment): + """executor_config.run_as_user/run_as_group set the pod securityContext for generic.""" + backend = _vllm_backend(k8s_backend) + config = _generic_config(run_as_user=1234, run_as_group=5678) + + created_dep = MagicMock() + created_dep.metadata.name = backend._get_resource_name(sample_deployment) + created_dep.metadata.uid = "dep-uid" + backend._apps_v1.create_namespaced_deployment.return_value = created_dep + + _sync_reconcilers(backend) + result = await backend.create_model_deployment( + ModelContext(model_deployment=sample_deployment, model_deployment_config=config, model_entity=None) + ) + + assert result.status == "PENDING" + dep_obj = backend._apps_v1.create_namespaced_deployment.call_args.kwargs["body"] + sec = dep_obj.spec.template.spec.security_context + assert sec is not None + assert sec.run_as_user == 1234 + assert sec.run_as_group == 5678 + + +@pytest.mark.asyncio +async def test_vllm_run_as_user_override_beats_engine_default(k8s_backend, sample_deployment): + """An explicit run_as_user overrides vLLM's default uid on the serving pod + puller.""" + backend = _vllm_backend(k8s_backend) + config = _vllm_config(gpu=1, run_as_user=4321) + + with patch.object(backend, "_resolve_model_source", return_value=("default", "qwen", None)): + _sync_reconcilers(backend) + result = await backend.create_model_deployment( + ModelContext(model_deployment=sample_deployment, model_deployment_config=config, model_entity=None) + ) + + assert result.status == "PENDING" + # Puller Job runs as the overridden uid (so it writes the PVC as that user); + # group falls back to the vLLM default since run_as_group was not set. + job = backend._batch_v1.create_namespaced_job.call_args.kwargs["body"] + job_sec = job.spec.template.spec.security_context + assert job_sec.run_as_user == 4321 + assert job_sec.run_as_group == backend._backend_config.default_vllm_group_id @pytest.mark.asyncio From 56f37999d0cff6988c2505e09ab0fa6c3054e2bf Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Fri, 26 Jun 2026 15:21:52 -0600 Subject: [PATCH 3/4] chore(sdk): regenerate Python SDK for run_as_user/run_as_group Stainless regeneration picks up the new ContainerExecutorConfig.run_as_user / run_as_group fields added in the prior commit. Note: the TypeScript web SDK (web/packages/sdk) was not regenerated here because node_modules are not installed in this worktree; `make update-web-sdk` (pnpm) should be run before merge to keep the web SDK in sync. Signed-off-by: Ben McCown --- sdk/python/nemo-platform/.nmpcontext/openapi.yaml | 15 +++++++++++++++ .../types/inference/container_executor_config.py | 14 ++++++++++++++ .../inference/container_executor_config_param.py | 14 ++++++++++++++ .../inference/test_deployment_configs.py | 8 ++++++++ 4 files changed, 51 insertions(+) diff --git a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml index 361a63db74e..db6ea52a4ec 100644 --- a/sdk/python/nemo-platform/.nmpcontext/openapi.yaml +++ b/sdk/python/nemo-platform/.nmpcontext/openapi.yaml @@ -8856,6 +8856,21 @@ components: that expose a non-standard health endpoint. type: string maxLength: 255 + run_as_user: + title: Run As User + description: Pod securityContext runAsUser (uid) for the serving container + (k8s backend only). If unset, the engine default applies (vLLM pins its + image's user; generic uses the image's own user). Ignored by the docker + backend. + type: integer + minimum: 0.0 + run_as_group: + title: Run As Group + description: Pod securityContext runAsGroup (gid) for the serving container + (k8s backend only). If unset, the engine default applies. Ignored by the + docker backend. + type: integer + minimum: 0.0 additional_envs: title: Additional Envs description: Additional environment variables for the deployment diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/inference/container_executor_config.py b/sdk/python/nemo-platform/src/nemo_platform/types/inference/container_executor_config.py index a12fe37562f..6c0d6f81202 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/types/inference/container_executor_config.py +++ b/sdk/python/nemo-platform/src/nemo_platform/types/inference/container_executor_config.py @@ -77,3 +77,17 @@ class ContainerExecutorConfig(BaseModel): (NIM engine on k8s). Allows advanced configuration options directly. Ignored by non-NIM engines. """ + + run_as_group: Optional[int] = None + """ + Pod securityContext runAsGroup (gid) for the serving container (k8s backend + only). If unset, the engine default applies. Ignored by the docker backend. + """ + + run_as_user: Optional[int] = None + """Pod securityContext runAsUser (uid) for the serving container (k8s backend + only). + + If unset, the engine default applies (vLLM pins its image's user; generic uses + the image's own user). Ignored by the docker backend. + """ diff --git a/sdk/python/nemo-platform/src/nemo_platform/types/inference/container_executor_config_param.py b/sdk/python/nemo-platform/src/nemo_platform/types/inference/container_executor_config_param.py index 90ef2c62f0a..5ffb0420437 100644 --- a/sdk/python/nemo-platform/src/nemo_platform/types/inference/container_executor_config_param.py +++ b/sdk/python/nemo-platform/src/nemo_platform/types/inference/container_executor_config_param.py @@ -80,3 +80,17 @@ class ContainerExecutorConfigParam(TypedDict, total=False): (NIM engine on k8s). Allows advanced configuration options directly. Ignored by non-NIM engines. """ + + run_as_group: int + """ + Pod securityContext runAsGroup (gid) for the serving container (k8s backend + only). If unset, the engine default applies. Ignored by the docker backend. + """ + + run_as_user: int + """Pod securityContext runAsUser (uid) for the serving container (k8s backend + only). + + If unset, the engine default applies (vLLM pins its image's user; generic uses + the image's own user). Ignored by the docker backend. + """ diff --git a/sdk/python/nemo-platform/tests/api_resources/inference/test_deployment_configs.py b/sdk/python/nemo-platform/tests/api_resources/inference/test_deployment_configs.py index ca127c6b4ca..0223b225956 100644 --- a/sdk/python/nemo-platform/tests/api_resources/inference/test_deployment_configs.py +++ b/sdk/python/nemo-platform/tests/api_resources/inference/test_deployment_configs.py @@ -69,6 +69,8 @@ def test_method_create_with_all_params(self, client: NeMoPlatform) -> None: "tolerations": [{"foo": "bar"}], }, "override_config": {"foo": "bar"}, + "run_as_group": 0, + "run_as_user": 0, }, model_spec={ "chat_template": "chat_template", @@ -222,6 +224,8 @@ def test_method_update_with_all_params(self, client: NeMoPlatform) -> None: "tolerations": [{"foo": "bar"}], }, "override_config": {"foo": "bar"}, + "run_as_group": 0, + "run_as_user": 0, }, model_spec={ "chat_template": "chat_template", @@ -465,6 +469,8 @@ async def test_method_create_with_all_params(self, async_client: AsyncNeMoPlatfo "tolerations": [{"foo": "bar"}], }, "override_config": {"foo": "bar"}, + "run_as_group": 0, + "run_as_user": 0, }, model_spec={ "chat_template": "chat_template", @@ -618,6 +624,8 @@ async def test_method_update_with_all_params(self, async_client: AsyncNeMoPlatfo "tolerations": [{"foo": "bar"}], }, "override_config": {"foo": "bar"}, + "run_as_group": 0, + "run_as_user": 0, }, model_spec={ "chat_template": "chat_template", From c348d0a5d96af4c137923a7e3ff130b3fee4bf2a Mon Sep 17 00:00:00 2001 From: Ben McCown Date: Mon, 29 Jun 2026 16:49:25 -0600 Subject: [PATCH 4/4] test(models): cover non-404 re-raise + type the run_as fixtures Address PR review nits: - Add regression tests asserting _existing_model_source re-raises non-404 ApiException from both the Job and PVC reads (only the 404 fallback was covered before). - Add concrete int | None type hints to the run_as_user/run_as_group params on the _vllm_config / _generic_config test helpers. Signed-off-by: Ben McCown --- .../test_k8s_nim_operator_backend.py | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) 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 0998fbf0e87..12d87acfcb0 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 @@ -1934,7 +1934,9 @@ async def test_delete_model_deployment_by_id_calls_delete_resources(mock_nmp_sdk # =========================================================================== -def _vllm_config(*, gpu: int = 1, lora_enabled: bool = False, run_as_user=None, run_as_group=None): +def _vllm_config( + *, gpu: int = 1, lora_enabled: bool = False, run_as_user: int | None = None, run_as_group: int | None = None +): """A minimal vLLM ModelDeploymentConfig-like object for dispatch/compile.""" return SimpleNamespace( engine="vllm", @@ -2007,8 +2009,8 @@ def _generic_config( gpu: int = 0, image="nvcr.io/nim/nvidia/nemoguard-jailbreak-detect", tag="1.10.1", - run_as_user=None, - run_as_group=None, + run_as_user: int | None = None, + run_as_group: int | None = None, ): """A minimal generic ModelDeploymentConfig-like object (no model weights).""" return SimpleNamespace( @@ -2593,6 +2595,31 @@ async def test_existing_model_source_none_when_job_and_pvc_absent(k8s_backend): assert backend._k8s_reconciler._existing_model_source("md-default-qwen") is None +@pytest.mark.asyncio +async def test_existing_model_source_reraises_job_api_errors(k8s_backend): + """A non-404 error reading the Job propagates (not swallowed / no PVC fallback).""" + backend = _vllm_backend(k8s_backend) + backend._batch_v1.read_namespaced_job.side_effect = _api_exception(500) + + _sync_reconcilers(backend) + with pytest.raises(k8s_client.exceptions.ApiException): + backend._k8s_reconciler._existing_model_source("md-default-qwen") + # The PVC fallback must not be consulted when the Job read fails hard. + backend._core_v1.read_namespaced_persistent_volume_claim.assert_not_called() + + +@pytest.mark.asyncio +async def test_existing_model_source_reraises_pvc_api_errors(k8s_backend): + """A non-404 error reading the PVC propagates (not swallowed / not None).""" + backend = _vllm_backend(k8s_backend) + backend._batch_v1.read_namespaced_job.side_effect = _api_exception(404) + backend._core_v1.read_namespaced_persistent_volume_claim.side_effect = _api_exception(500) + + _sync_reconcilers(backend) + with pytest.raises(k8s_client.exceptions.ApiException): + backend._k8s_reconciler._existing_model_source("md-default-qwen") + + @pytest.mark.asyncio async def test_vllm_update_during_pull_is_noop(k8s_backend, sample_deployment): """Unchanged source, serving Deployment not yet created but puller Job present.