Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ Check for an existing instance before starting (`lsof -iTCP:8080 -sTCP:LISTEN` o

```bash
tmux -f /exec-daemon/tmux.portal.conf new-session -d -s nemo-platform -c /workspace -- \
'export NMP_BASE_URL=http://localhost:8080 && uv run nemo services run --service-group all --port 8080'
'export NMP_BASE_URL=http://localhost:8080 && uv run nemo services run --service-group all --controller-group all --port 8080'
```

Wait for readiness: `curl -sf http://localhost:8080/health/ready` → `{"status":"ready"}`.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ nemo services run --services jobs --controllers jobs
# Run a predefined service group
nemo services run --service-group core # Infrastructure only
nemo services run --service-group api # Application services
nemo services run --service-group all # Everything
nemo services run --service-group all --controller-group all # All services and controllers
```

The platform binds to `127.0.0.1:8080` by default. You can customize the host and port:
Expand Down
5 changes: 5 additions & 0 deletions openapi/ga/individual/platform.openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions openapi/ga/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions openapi/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/nmp_platform/config/local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# export NMP_BASE_URL=http://127.0.0.1:8080
# uv run nemo services run --host 127.0.0.1 --port 8080
#
# Use default service set (omit --services) or --service-group all. Then:
# Use default service set (omit --services) or --service-group all --controller-group all. Then:
# nemo auth login --unsigned-token
# uv run nemo-platform run task --task nmp.platform_seed

Expand Down
8 changes: 8 additions & 0 deletions packages/nmp_platform_runner/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,14 @@ def test_no_arguments_defaults_to_all_services_and_default_controllers():
assert resolved.controllers.issuperset({"jobs", "models", "entities"})


def test_service_group_all_does_not_start_controllers():
"""Helm API pods use --service-group=all with no controllers; do not auto-start them."""
resolved = resolve(service_group="all")

assert "entities" in resolved.services
assert resolved.controllers == set()


def test_service_group_core_resolves_core_services_only():
resolved = resolve(service_group="core")

Expand Down
5 changes: 5 additions & 0 deletions sdk/python/nemo-platform/.nmpcontext/openapi.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

from fastapi import APIRouter, HTTPException, Query, status
from nmp.common.api.common import DeleteResponse, GenericSortField, Page, PaginationData
from nmp.common.api.filter import ComparisonOperation, FilterOperator
from nmp.common.api.filter import ComparisonOperation, FilterOperation, FilterOperator, LogicalOperation
from nmp.common.auth.models import Principal
from nmp.core.entities.api.dependencies import AuthClientDep, EntityRepository, WorkspaceRepository
from nmp.core.entities.api.v2.utils import (
Expand All @@ -50,6 +50,22 @@
API_TAG = "Entity Store"
logger = logging.getLogger(__name__)

_ACTIVE_WORKSPACE_FILTER = ComparisonOperation(
operator=FilterOperator.EQ,
field="deletion_stage",
value=None,
)


def _exclude_deleting_workspaces(filter_op: FilterOperation | None) -> FilterOperation:
"""AND the caller filter with deletion_stage IS NULL so list matches GET/DELETE."""
if filter_op is None:
return _ACTIVE_WORKSPACE_FILTER
return LogicalOperation(
operator=FilterOperator.AND,
operations=[filter_op, _ACTIVE_WORKSPACE_FILTER],
)


def _principal_for_role_binding(principal: Principal) -> str | None:
"""Return the identifier to store on role bindings (email preferred for human-readable membership).
Expand Down Expand Up @@ -265,6 +281,9 @@ async def create_workspace(
description=textwrap.dedent("""
List all workspaces with pagination.

Workspaces marked for deletion (non-null deletion_stage) are omitted so the
list matches GET/DELETE, which treat those workspaces as not found.

When authentication is enabled, only workspaces the principal has access to
are returned. Service principals and platform admins have access to all workspaces.

Expand All @@ -291,8 +310,7 @@ async def list_workspaces(
# Get accessible workspaces for access control
accessible_workspaces = await get_accessible_workspaces(entity_repository)

# Build combined filter for workspace access and user's filter
combined_filter = add_workspace_filtering(accessible_workspaces, filter, field="name")
combined_filter = _exclude_deleting_workspaces(add_workspace_filtering(accessible_workspaces, filter, field="name"))

workspaces, total = await repository.list_workspaces(
page=page,
Expand Down
111 changes: 111 additions & 0 deletions services/core/entities/tests/integration/test_workspace_deletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import pytest
from httpx import AsyncClient
from nmp.common.api.filter import ComparisonOperation, FilterOperator
from nmp.common.auth import get_auth_client
from nmp.common.auth.client import AuthClient
from nmp.common.auth.models import Principal
Expand Down Expand Up @@ -250,3 +251,113 @@ async def test_service_principal_can_access_deleting_workspace(self, client: Asy
finally:
# Clean up override
app.dependency_overrides.clear()


def _workspace_names_and_total(list_response) -> tuple[set[str], int]:
body = list_response.json()
names = {ws["name"] for ws in body["data"]}
return names, body["pagination"]["total_results"]


@pytest.mark.integration
@pytest.mark.asyncio
class TestWorkspaceListExcludesDeleting:
"""List must not return workspaces the single-entity GET path already treats as gone."""

async def test_list_excludes_pending_workspace_after_delete(self, client: AsyncClient):
workspace_name = "list-hide-pending"
created = await client.post(
"/apis/entities/v2/workspaces",
json={"name": workspace_name, "description": "Pending deletion list test"},
)
assert created.status_code == 201

listed_before = await client.get("/apis/entities/v2/workspaces", params={"page_size": 100})
assert listed_before.status_code == 200
names_before, total_before = _workspace_names_and_total(listed_before)
assert workspace_name in names_before

deleted = await client.delete(f"/apis/entities/v2/workspaces/{workspace_name}")
assert deleted.status_code == 200

listed_after = await client.get("/apis/entities/v2/workspaces", params={"page_size": 100})
assert listed_after.status_code == 200
names_after, total_after = _workspace_names_and_total(listed_after)
assert workspace_name not in names_after
assert total_after == total_before - 1

get_response = await client.get(f"/apis/entities/v2/workspaces/{workspace_name}")
assert get_response.status_code == 404

async def test_list_still_shows_live_sibling_after_delete(self, client: AsyncClient):
keep_name = "list-keep-sibling"
delete_name = "list-delete-sibling"
for name in (keep_name, delete_name):
created = await client.post(
"/apis/entities/v2/workspaces",
json={"name": name, "description": "Sibling list test"},
)
assert created.status_code == 201

deleted = await client.delete(f"/apis/entities/v2/workspaces/{delete_name}")
assert deleted.status_code == 200

listed = await client.get("/apis/entities/v2/workspaces", params={"page_size": 100})
assert listed.status_code == 200
names, _ = _workspace_names_and_total(listed)
assert keep_name in names
assert delete_name not in names

async def test_cleanup_filter_still_finds_pending_workspace(self, client: AsyncClient, repos):
workspace_name = "cleanup-filter-pending"
created = await client.post(
"/apis/entities/v2/workspaces",
json={"name": workspace_name, "description": "Cleanup query contract"},
)
assert created.status_code == 201

deleted = await client.delete(f"/apis/entities/v2/workspaces/{workspace_name}")
assert deleted.status_code == 200

workspace_repo: WorkspaceRepositoryInterface = repos["workspace"]
pending, _ = await workspace_repo.list_workspaces(
filter_op=ComparisonOperation(
operator=FilterOperator.EQ,
field="deletion_stage",
value=WorkspaceDeletionStage.PENDING,
),
page_size=1,
)
assert any(ws.name == workspace_name for ws in pending)

@pytest.mark.parametrize(
"stage",
[WorkspaceDeletionStage.DELETING, WorkspaceDeletionStage.FAILED],
)
async def test_list_excludes_deleting_and_failed_workspaces(
self,
client: AsyncClient,
repos,
stage: WorkspaceDeletionStage,
):
workspace_name = f"list-hide-{stage.value}"
created = await client.post(
"/apis/entities/v2/workspaces",
json={"name": workspace_name, "description": f"{stage.value} list test"},
)
assert created.status_code == 201

workspace_repo: WorkspaceRepositoryInterface = repos["workspace"]
marked = await workspace_repo.mark_workspace_for_deletion(
name=workspace_name,
deletion_stage=stage,
)
assert marked is True

listed = await client.get("/apis/entities/v2/workspaces", params={"page_size": 100})
assert listed.status_code == 200
names, _ = _workspace_names_and_total(listed)
assert workspace_name not in names

get_response = await client.get(f"/apis/entities/v2/workspaces/{workspace_name}")
assert get_response.status_code == 404
Loading