Skip to content

Commit 05cd69f

Browse files
authored
chore: harden container labeling and e2e tests (#918)
Signed-off-by: Matt Kornfield <mkornfield@nvidia.com>
1 parent 951a454 commit 05cd69f

15 files changed

Lines changed: 601 additions & 40 deletions

File tree

‎e2e/services_pool.py‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,12 @@ def _register_config_state(
290290

291291
def _materialize_config_path(self, state: ModuleConfigState) -> ModuleConfigState:
292292
data_dir = e2e_services_data_dir(self._get_log_dir(), state.key.config_hash)
293-
rendered_config_data = _render_e2e_config_for_backend(state.config_data, data_dir, state.harness_config)
293+
rendered_config_data = _render_e2e_config_for_backend(
294+
state.config_data,
295+
data_dir,
296+
state.harness_config,
297+
state.key.config_hash,
298+
)
294299
rendered_config = yaml.safe_dump(rendered_config_data, default_flow_style=False, sort_keys=True)
295300
config_path = self._get_generated_config_dir() / f"platform-{state.key.config_hash}.yaml"
296301
if not config_path.exists():
@@ -546,7 +551,12 @@ def e2e_services_data_dir(log_dir: Path, config_hash: str) -> Path:
546551
return log_dir / f"data-{config_hash}"
547552

548553

549-
def with_e2e_instance_paths(config_data: dict[str, Any], data_dir: Path) -> dict[str, Any]:
554+
def with_e2e_instance_paths(
555+
config_data: dict[str, Any],
556+
data_dir: Path,
557+
*,
558+
resource_scope: str | None = None,
559+
) -> dict[str, Any]:
550560
"""Return config data with per-instance filesystem paths rooted under ``data_dir``."""
551561
rendered = deepcopy(config_data)
552562
subprocess_working_dir = str(data_dir / "subprocess-jobs")
@@ -577,9 +587,27 @@ def with_e2e_instance_paths(config_data: dict[str, Any], data_dir: Path) -> dict
577587
if isinstance(default_storage_config, dict) and default_storage_config.get("type") == "local":
578588
default_storage_config["path"] = files_root
579589

590+
if resource_scope:
591+
_stamp_e2e_docker_deployment_scope(rendered, resource_scope)
592+
580593
return rendered
581594

582595

596+
def _stamp_e2e_docker_deployment_scope(config_data: dict[str, Any], resource_scope: str) -> None:
597+
deployments = config_data.get("deployments")
598+
if not isinstance(deployments, dict):
599+
return
600+
executors = deployments.get("executors")
601+
if not isinstance(executors, list):
602+
return
603+
for executor in executors:
604+
if not isinstance(executor, dict) or executor.get("backend") != "docker":
605+
continue
606+
executor_config = executor.setdefault("config", {})
607+
if isinstance(executor_config, dict):
608+
executor_config.setdefault("resource_scope", resource_scope)
609+
610+
583611
# The authz e2e suite (``e2e/authz_oidc``) editable-installs intentionally-broken
584612
# fixture plugins (named ``harness-*``) into the shared venv to exercise the authz
585613
# fail-modes. Their ``nemo.services`` entry points persist for the rest of the pytest
@@ -610,11 +638,11 @@ def _e2e_backend(harness_config: E2EHarnessConfig) -> Literal["subprocess", "doc
610638

611639

612640
def _render_e2e_config_for_backend(
613-
config_data: dict[str, Any], data_dir: Path, harness_config: E2EHarnessConfig
641+
config_data: dict[str, Any], data_dir: Path, harness_config: E2EHarnessConfig, config_hash: str
614642
) -> dict[str, Any]:
615643
if _e2e_backend(harness_config) in {"docker", "docker_compose"}:
616644
return deepcopy(config_data)
617-
return with_e2e_instance_paths(config_data, data_dir)
645+
return with_e2e_instance_paths(config_data, data_dir, resource_scope=f"e2e-{config_hash}")
618646

619647

620648
class DockerBackendOverrides(TypedDict, total=False):

‎e2e/test_anonymizer_plugin.py‎

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from collections.abc import Iterator
1515
from contextlib import suppress
1616
from pathlib import Path
17+
from typing import cast
1718

1819
import data_designer.config as dd
1920
import httpx
@@ -28,7 +29,11 @@
2829
AnonymizerConfigValidationError,
2930
AnonymizerPreviewError,
3031
)
31-
from nemo_anonymizer_plugin.sdk.job_resources import TERMINAL_INCOMPLETE_STATUSES, AnonymizerJobResource
32+
from nemo_anonymizer_plugin.sdk.job_resources import (
33+
MAX_CONSECUTIVE_POLL_ERRORS,
34+
TERMINAL_INCOMPLETE_STATUSES,
35+
AnonymizerJobResource,
36+
)
3237
from nemo_anonymizer_plugin.sdk.resources import AnonymizerPreviewResult
3338
from nemo_platform import NeMoPlatform
3439
from nemo_platform_plugin.files.client import FilesClient
@@ -113,13 +118,20 @@ def _workspace_client(sdk: NeMoPlatform, workspace: str) -> NeMoPlatform:
113118
)
114119

115120

121+
def _require_workspace(workspace: str | None) -> str:
122+
assert workspace is not None
123+
return workspace
124+
125+
116126
def _anonymizer_url(sdk: NeMoPlatform, workspace: str, path: str) -> str:
117127
return f"{str(sdk.base_url).rstrip('/')}/apis/anonymizer/v2/workspaces/{workspace}/{path.lstrip('/')}"
118128

119129

120-
def _raw_anonymizer_post(sdk: NeMoPlatform, workspace: str, path: str, payload: dict[str, object]) -> httpx.Response:
130+
def _raw_anonymizer_post(
131+
sdk: NeMoPlatform, workspace: str | None, path: str, payload: dict[str, object]
132+
) -> httpx.Response:
121133
return sdk._client.post(
122-
_anonymizer_url(sdk, workspace, path),
134+
_anonymizer_url(sdk, _require_workspace(workspace), path),
123135
json=payload,
124136
headers=_string_headers(sdk),
125137
timeout=sdk.timeout,
@@ -166,12 +178,12 @@ def _rewrite_config() -> AnonymizerConfig:
166178
)
167179

168180

169-
def _fileset_ref(workspace: str, fileset: str, path: str) -> str:
170-
return f"{workspace}/{fileset}#{path}"
181+
def _fileset_ref(workspace: str | None, fileset: str, path: str) -> str:
182+
return f"{_require_workspace(workspace)}/{fileset}#{path}"
171183

172184

173-
def _fileset_uri_ref(workspace: str, fileset: str, path: str) -> str:
174-
return f"fileset://{workspace}/{fileset}#{path}"
185+
def _fileset_uri_ref(workspace: str | None, fileset: str, path: str) -> str:
186+
return f"fileset://{_require_workspace(workspace)}/{fileset}#{path}"
175187

176188

177189
def _input_spec(source: str, *, text_column: str = TEXT_COLUMN) -> AnonymizerInputSpec:
@@ -281,14 +293,32 @@ def _job_name(job: AnonymizerJobResource) -> str:
281293

282294
def _wait_for_anonymizer_job(job: AnonymizerJobResource, *, timeout_seconds: float) -> None:
283295
deadline = time.monotonic() + timeout_seconds
284-
status = job.get_job_status()
296+
status = None
297+
consecutive_poll_errors = 0
298+
last_poll_error: Exception | None = None
285299
while status not in {"completed", *TERMINAL_INCOMPLETE_STATUSES}:
286300
if time.monotonic() >= deadline:
287-
logs = job.get_logs()
288-
tail = logs[-5:] if logs else []
289-
raise TimeoutError(f"Anonymizer job {_job_name(job)} timed out with status {status!r}; logs={tail!r}")
290-
time.sleep(ANONYMIZER_POLL_INTERVAL_SECONDS)
291-
status = job.get_job_status()
301+
try:
302+
logs = job.get_logs()
303+
tail = logs[-5:] if logs else []
304+
except Exception as exc:
305+
tail = [f"<log retrieval failed: {exc!r}>"]
306+
raise TimeoutError(
307+
f"Anonymizer job {_job_name(job)} timed out with status {status!r}; "
308+
f"last_poll_error={last_poll_error!r}; logs={tail!r}"
309+
)
310+
try:
311+
status = job.get_job_status()
312+
except Exception as exc:
313+
consecutive_poll_errors += 1
314+
last_poll_error = exc
315+
if consecutive_poll_errors >= MAX_CONSECUTIVE_POLL_ERRORS:
316+
raise
317+
else:
318+
consecutive_poll_errors = 0
319+
last_poll_error = None
320+
if status not in {"completed", *TERMINAL_INCOMPLETE_STATUSES}:
321+
time.sleep(ANONYMIZER_POLL_INTERVAL_SECONDS)
292322
assert status == "completed"
293323

294324

@@ -433,7 +463,11 @@ def test_mock_provider_chat_completion_works_through_minikube_ingress(
433463
},
434464
)
435465

436-
assert SUBSTITUTE_NAME in response["choices"][0]["message"]["content"]
466+
choices = cast(list[dict[str, object]], response["choices"])
467+
message = cast(dict[str, object], choices[0]["message"])
468+
content = message["content"]
469+
assert isinstance(content, str)
470+
assert SUBSTITUTE_NAME in content
437471

438472

439473
def test_file_upload_round_trips_through_minikube_ingress(
@@ -558,7 +592,7 @@ def test_preview_missing_text_column_is_rejected(
558592

559593

560594
def test_preview_invalid_strategy_payload_is_rejected(anonymizer_sdk: NeMoPlatform, anonymizer_fileset: str) -> None:
561-
payload = {
595+
payload: dict[str, object] = {
562596
"config": {"replace": {"kind": "explode"}, "emit_telemetry": False},
563597
"data": {
564598
"source": _fileset_ref(anonymizer_sdk.workspace, anonymizer_fileset, CSV_REMOTE_PATH),

‎e2e/test_nemo_agents.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,15 @@ def _delete_deployment_if_exists(sdk: NeMoPlatform, *, workspace: str, name: str
134134
try:
135135
sdk.agents.deployments.delete(name, workspace=workspace)
136136
except httpx.HTTPStatusError as exc:
137-
if exc.response.status_code != 404:
138-
raise
137+
if exc.response.status_code == 404:
138+
return
139+
if exc.response.status_code in {409, 500}:
140+
try:
141+
sdk.agents.deployments.get(name, workspace=workspace)
142+
except httpx.HTTPStatusError as get_exc:
143+
if get_exc.response.status_code == 404:
144+
return
145+
raise
139146

140147

141148
def _get_deployment_log_text(sdk: NeMoPlatform, *, workspace: str, name: str) -> str:

‎packages/nmp_testing/tests/unit/test_e2e_harness.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,22 @@ def test_with_e2e_instance_paths_namespaces_local_filesystem_paths(tmp_path):
6464
jobs_config = cast(dict[str, Any], config_data["jobs"])
6565
executors = cast(list[dict[str, Any]], jobs_config["executors"])
6666
assert executors[0]["config"]["working_directory"] == ".tmp/e2e/subprocess-jobs"
67+
68+
69+
def test_with_e2e_instance_paths_scopes_docker_deployments_executor(tmp_path):
70+
data_dir = tmp_path / "data-abc123def456"
71+
config_data: dict[str, Any] = {
72+
"deployments": {
73+
"executors": [
74+
{"name": "local-docker", "backend": "docker", "config": {"pull_images": False}},
75+
{"name": "local-k8s", "backend": "k8s", "config": {}},
76+
],
77+
},
78+
}
79+
80+
rendered = services_pool.with_e2e_instance_paths(config_data, data_dir, resource_scope="e2e-abc123def456")
81+
82+
deployments = cast(dict[str, Any], rendered["deployments"])
83+
executors = cast(list[dict[str, Any]], deployments["executors"])
84+
assert executors[0]["config"]["resource_scope"] == "e2e-abc123def456"
85+
assert "resource_scope" not in executors[1]["config"]

‎plugins/nemo-agents/src/nemo_agents_plugin/api/v2/deployments.py‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
router = APIRouter()
4646

4747
_deployment_filter_dep = make_filter_obj_dep(DeploymentFilter)
48+
_DELETE_MARK_ATTEMPTS = 3
4849

4950

5051
@router.post("/deployments", response_model=AgentDeployment, status_code=201, tags=["Agent Deployments"])
@@ -198,9 +199,42 @@ async def delete_deployment(
198199
Marks the deployment as ``deleting``. The controller terminates the
199200
subprocess and removes the entity on the next reconcile cycle.
200201
"""
202+
for attempt in range(_DELETE_MARK_ATTEMPTS):
203+
try:
204+
await _mark_deployment_deleting_once(
205+
entity_client,
206+
workspace=workspace,
207+
name=name,
208+
retrying=attempt > 0,
209+
)
210+
return
211+
except HTTPException:
212+
raise
213+
except NemoEntityConflictError as exc:
214+
if attempt + 1 >= _DELETE_MARK_ATTEMPTS:
215+
raise HTTPException(
216+
status_code=409,
217+
detail=f"Deployment '{name}' is being modified concurrently.",
218+
) from exc
219+
logger.info("Retrying delete for deployment '%s' after concurrent update", name)
220+
except Exception as exc:
221+
logger.exception("Failed to mark deployment '%s' as deleting", name)
222+
raise HTTPException(status_code=500, detail="Failed to update deployment.") from exc
223+
224+
225+
async def _mark_deployment_deleting_once(
226+
entity_client: NemoEntitiesClient,
227+
*,
228+
workspace: str,
229+
name: str,
230+
retrying: bool,
231+
) -> None:
201232
try:
202233
dep = await entity_client.get(AgentDeployment, name=name, workspace=workspace)
203234
except NemoEntityNotFoundError as exc:
235+
if retrying:
236+
logger.info("Deployment '%s' already deleted during delete retry", name)
237+
return
204238
raise HTTPException(
205239
status_code=404,
206240
detail=f"Deployment '{name}' not found in workspace '{workspace}'.",
@@ -209,11 +243,12 @@ async def delete_deployment(
209243
logger.exception("Failed to look up deployment '%s' before delete", name)
210244
raise HTTPException(status_code=500, detail="Failed to look up deployment.") from exc
211245

246+
if dep.status == "deleting":
247+
return
248+
212249
dep.status = "deleting"
213250
try:
214251
await entity_client.update(dep)
215252
except NemoEntityNotFoundError:
216253
logger.info("Deployment '%s' already deleted before status update", name)
217-
except Exception as exc:
218-
logger.exception("Failed to mark deployment '%s' as deleting", name)
219-
raise HTTPException(status_code=500, detail="Failed to update deployment.") from exc
254+
return

‎plugins/nemo-agents/tests/unit/test_deployments_api.py‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
from fastapi.testclient import TestClient
1414
from nemo_agents_plugin.api.v2 import deployments as deployments_router_module
1515
from nemo_agents_plugin.api.v2.dependencies import get_entity_client
16-
from nemo_agents_plugin.entities import NEMO_AGENTS_SPEC_CONFIG_FORMAT, Agent, AgentDeployment
16+
from nemo_agents_plugin.entities import NEMO_AGENTS_SPEC_CONFIG_FORMAT, Agent, AgentDeployment, DeploymentStatus
17+
from nemo_platform_plugin.entity_client import NemoEntityConflictError, NemoEntityNotFoundError
1718

1819
NOW = datetime.now(timezone.utc)
1920

@@ -56,6 +57,19 @@ def _make_agent(
5657
return agent
5758

5859

60+
def _make_deployment(
61+
*,
62+
name: str = "fabric-dep",
63+
workspace: str = "default",
64+
agent: str = "fabric-agent",
65+
status: DeploymentStatus = "pending",
66+
) -> AgentDeployment:
67+
deployment = AgentDeployment(name=name, workspace=workspace, agent=agent, status=status)
68+
deployment._id = f"deployment-{name}-id"
69+
deployment._created_at = NOW
70+
return deployment
71+
72+
5973
def _test_client(mock_entity_client: AsyncMock) -> TestClient:
6074
app = FastAPI()
6175
app.include_router(
@@ -106,3 +120,65 @@ def test_create_rejects_invalid_platform_agent_config(self) -> None:
106120
assert resp.status_code == 400
107121
assert "Invalid agent config" in resp.json()["detail"]
108122
mock_entity_client.create.assert_not_called()
123+
124+
125+
class TestDeleteDeployment:
126+
def test_delete_marks_deployment_deleting(self) -> None:
127+
mock_entity_client = AsyncMock()
128+
mock_entity_client.get = AsyncMock(return_value=_make_deployment(status="starting"))
129+
mock_entity_client.update = AsyncMock(return_value=None)
130+
client = _test_client(mock_entity_client)
131+
132+
resp = client.delete("/apis/agents/v2/workspaces/default/deployments/fabric-dep")
133+
134+
assert resp.status_code == 204
135+
updated: AgentDeployment = mock_entity_client.update.call_args[0][0]
136+
assert updated.status == "deleting"
137+
138+
def test_delete_retries_concurrent_update_conflict(self) -> None:
139+
mock_entity_client = AsyncMock()
140+
mock_entity_client.get = AsyncMock(
141+
side_effect=[
142+
_make_deployment(status="pending"),
143+
_make_deployment(status="starting"),
144+
]
145+
)
146+
mock_entity_client.update = AsyncMock(side_effect=[NemoEntityConflictError("conflict"), None])
147+
client = _test_client(mock_entity_client)
148+
149+
resp = client.delete("/apis/agents/v2/workspaces/default/deployments/fabric-dep")
150+
151+
assert resp.status_code == 204
152+
assert mock_entity_client.get.await_count == 2
153+
assert mock_entity_client.update.await_count == 2
154+
155+
def test_delete_returns_success_when_entity_disappears_during_retry(self) -> None:
156+
mock_entity_client = AsyncMock()
157+
mock_entity_client.get = AsyncMock(
158+
side_effect=[
159+
_make_deployment(status="pending"),
160+
NemoEntityNotFoundError("gone"),
161+
]
162+
)
163+
mock_entity_client.update = AsyncMock(side_effect=NemoEntityConflictError("conflict"))
164+
client = _test_client(mock_entity_client)
165+
166+
resp = client.delete("/apis/agents/v2/workspaces/default/deployments/fabric-dep")
167+
168+
assert resp.status_code == 204
169+
170+
def test_delete_returns_409_when_conflicts_exhausted(self) -> None:
171+
mock_entity_client = AsyncMock()
172+
mock_entity_client.get = AsyncMock(
173+
side_effect=[
174+
_make_deployment(status="pending")
175+
for _ in range(deployments_router_module._DELETE_MARK_ATTEMPTS) # noqa: SLF001
176+
]
177+
)
178+
mock_entity_client.update = AsyncMock(side_effect=NemoEntityConflictError("conflict"))
179+
client = _test_client(mock_entity_client)
180+
181+
resp = client.delete("/apis/agents/v2/workspaces/default/deployments/fabric-dep")
182+
183+
assert resp.status_code == 409
184+
assert mock_entity_client.update.await_count == deployments_router_module._DELETE_MARK_ATTEMPTS # noqa: SLF001

0 commit comments

Comments
 (0)