diff --git a/python/understack-workflows/tests/conftest.py b/python/understack-workflows/tests/conftest.py index e313fb175..121e2cf47 100644 --- a/python/understack-workflows/tests/conftest.py +++ b/python/understack-workflows/tests/conftest.py @@ -59,19 +59,35 @@ def _get_project(project_id): **project_data, "id": project_id, "domain_id": project_data["domain_id"].hex, + "is_domain": False, } elif project_id == project_data["domain_id"].hex: + # When fetching the domain as a project, mark it as a domain data = { **project_data, "id": project_data["domain_id"].hex, "domain_id": "default", + "is_domain": True, } else: raise openstack.exceptions.NotFoundException # pyright: ignore[reportAttributeAccessIssue] return openstack.identity.v3.project.Project(**data) # pyright: ignore[reportAttributeAccessIssue] + def _get_domain(domain_id): + if domain_id == project_data["domain_id"].hex: + data = { + "id": domain_id, + "name": "test domain", + "description": "this is a test domain", + "enabled": True, + } + else: + raise openstack.exceptions.NotFoundException # pyright: ignore[reportAttributeAccessIssue] + return openstack.identity.v3.domain.Domain(**data) # pyright: ignore[reportAttributeAccessIssue] + conn = MagicMock(spec_set=openstack.connection.Connection) # pyright: ignore[reportAttributeAccessIssue] conn.identity.get_project.side_effect = _get_project + conn.identity.get_domain.side_effect = _get_domain return conn diff --git a/python/understack-workflows/tests/json_samples/keystone-is-domain-project-updated.json b/python/understack-workflows/tests/json_samples/keystone-is-domain-project-updated.json new file mode 100644 index 000000000..b5e8d8f13 --- /dev/null +++ b/python/understack-workflows/tests/json_samples/keystone-is-domain-project-updated.json @@ -0,0 +1,38 @@ +{ + "_unique_id": "8924fada50c24c98b329b9b8615d86eb", + "event_type": "identity.project.updated", + "message_id": "3d15601b-fd77-43fe-b38e-33f83bb2365e", + "payload": { + "action": "updated.project", + "eventTime": "2025-12-09T09:12:12.161053+0000", + "eventType": "activity", + "id": "b483fc86-a977-5486-8f12-78efb6425ccc", + "initiator": { + "host": { + "address": "10.64.50.136", + "agent": "python-keystoneclient" + }, + "id": "8181a4bc4466592d8009fd2874f05756", + "name": "xyz@example.com", + "project_id": "32e02632f4f04415bab5895d1e7247b7", + "request_id": "req-9b49faec-3fe2-48bb-9107-925e094741c3", + "typeURI": "service/security/account/user", + "user_id": "141aa00793cd2bb035555c44b3097fda07591ead23389027629f05053bad5d7a", + "username": "xyz@example.com" + }, + "observer": { + "id": "ad1c83a7f5f746d2a04fcc8dda226368", + "typeURI": "service/security" + }, + "outcome": "success", + "resource_info": "94d23ad2674f46e08259a24bfe2b698e", + "target": { + "id": "94d23ad2674f46e08259a24bfe2b698e", + "typeURI": "data/security/project" + }, + "typeURI": "http://schemas.dmtf.org/cloud/audit/1.0/event" + }, + "priority": "INFO", + "publisher_id": "identity.keystone-api-5599bd6d8-vv76f", + "timestamp": "2025-12-09 09:12:12.161534" +} diff --git a/python/understack-workflows/tests/test_sync_keystone.py b/python/understack-workflows/tests/test_sync_keystone.py index 2aa017745..f2441a698 100644 --- a/python/understack-workflows/tests/test_sync_keystone.py +++ b/python/understack-workflows/tests/test_sync_keystone.py @@ -74,8 +74,8 @@ def test_create_project( domain_id: uuid.UUID, ): ret = do_action(os_conn, mock_pynautobot_api, Event.ProjectCreate, project_id) - os_conn.identity.get_project.assert_any_call(domain_id.hex) os_conn.identity.get_project.assert_any_call(project_id.hex) + os_conn.identity.get_domain.assert_any_call(domain_id.hex) assert ret == 0 @@ -86,8 +86,24 @@ def test_update_project( domain_id: uuid.UUID, ): ret = do_action(os_conn, mock_pynautobot_api, Event.ProjectUpdate, project_id) - os_conn.identity.get_project.assert_any_call(domain_id.hex) os_conn.identity.get_project.assert_any_call(project_id.hex) + os_conn.identity.get_domain.assert_any_call(domain_id.hex) + assert ret == 0 + + +def test_update_project_domain_skipped( + os_conn, + mock_pynautobot_api, + domain_id: uuid.UUID, +): + """Test that domains are skipped during update events.""" + ret = do_action(os_conn, mock_pynautobot_api, Event.ProjectUpdate, domain_id) + # Should fetch the project to check if it's a domain + os_conn.identity.get_project.assert_called_once_with(domain_id.hex) + # Should NOT call get_domain or create/update tenant since it's a domain + os_conn.identity.get_domain.assert_not_called() + mock_pynautobot_api.tenancy.tenants.get.assert_not_called() + mock_pynautobot_api.tenancy.tenants.create.assert_not_called() assert ret == 0 diff --git a/python/understack-workflows/understack_workflows/main/sync_keystone.py b/python/understack-workflows/understack_workflows/main/sync_keystone.py index 39d866bd0..d955c4564 100644 --- a/python/understack-workflows/understack_workflows/main/sync_keystone.py +++ b/python/understack-workflows/understack_workflows/main/sync_keystone.py @@ -6,6 +6,7 @@ from typing import cast import pynautobot +from openstack.identity.v3.project import Project from pynautobot.core.response import Record from understack_workflows.helpers import credential @@ -48,16 +49,41 @@ def argument_parser(): return parser -def _tenant_attrs(conn: Connection, project_id: uuid.UUID) -> tuple[str, str]: - project = conn.identity.get_project(project_id.hex) # type: ignore +def _get_project(conn: Connection, project_id: uuid.UUID) -> Project: + """Fetch a project from OpenStack by UUID.""" + return conn.identity.get_project(project_id.hex) # type: ignore + + +def _is_domain(project: Project) -> bool: + """Check if a project is actually a domain. + + Returns True if the project is a domain, False otherwise. + Domains should not be synced to Nautobot. + + Note: This check is only needed for update events, since Keystone sends + identity.project.updated for both projects AND domains (it sends both + identity.project.updated and identity.domain.updated for domain updates). + For create events, domains only send identity.domain.created. + """ + return getattr(project, "is_domain", False) + + +def _tenant_attrs(conn: Connection, project: Project) -> tuple[str, str]: domain_id = project.domain_id - is_default_domain = domain_id == "default" - if is_default_domain: + if domain_id == "default": domain_name = "default" - else: - domain = conn.identity.get_project(domain_id) # type: ignore + elif domain_id: + domain = conn.identity.get_domain(domain_id) # type: ignore domain_name = domain.name + else: + # This shouldn't happen for regular projects + logger.error( + "Project %s has no domain_id. " + "This indicates a malformed project. Using 'unknown' as domain name.", + project.id, + ) + domain_name = "unknown" tenant_name = f"{domain_name}:{project.name}" return tenant_name, str(project.description) @@ -77,7 +103,9 @@ def handle_project_create( conn: Connection, nautobot: pynautobot.api, project_id: uuid.UUID ) -> int: logger.info("got request to create tenant %s", project_id.hex) - tenant_name, tenant_description = _tenant_attrs(conn, project_id) + + project = _get_project(conn, project_id) + tenant_name, tenant_description = _tenant_attrs(conn, project) try: tenant = nautobot.tenancy.tenants.create( @@ -97,7 +125,15 @@ def handle_project_update( conn: Connection, nautobot: pynautobot.api, project_id: uuid.UUID ) -> int: logger.info("got request to update tenant %s", project_id.hex) - tenant_name, tenant_description = _tenant_attrs(conn, project_id) + + project = _get_project(conn, project_id) + if _is_domain(project): + logger.info( + "Skipping domain %s - domains are not synced to Nautobot", project_id.hex + ) + return _EXIT_SUCCESS + + tenant_name, tenant_description = _tenant_attrs(conn, project) existing_tenant = nautobot.tenancy.tenants.get(id=project_id) logger.info("existing_tenant: %s", existing_tenant) @@ -127,12 +163,14 @@ def handle_project_update( def handle_project_delete( - conn: Connection, nautobot: pynautobot.api, project_id: uuid.UUID + _: Connection, nautobot: pynautobot.api, project_id: uuid.UUID ) -> int: logger.info("got request to delete tenant %s", project_id) tenant = nautobot.tenancy.tenants.get(id=project_id) if not tenant: - logger.warning("tenant %s does not exist, nothing to delete", project_id) + logger.warning( + "tenant %s does not exist in Nautobot, nothing to delete", project_id + ) return _EXIT_SUCCESS _unmap_tenant_from_devices(tenant_id=project_id, nautobot=nautobot) @@ -156,10 +194,6 @@ def do_action( return handle_project_update(conn, nautobot, project_id) case Event.ProjectDelete: return handle_project_delete(conn, nautobot, project_id) - case _: - logger.error("Cannot handle event: %s", event) - return _EXIT_EVENT_UNKNOWN - return _EXIT_EVENT_UNKNOWN def main() -> int: