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
16 changes: 16 additions & 0 deletions python/understack-workflows/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
20 changes: 18 additions & 2 deletions python/understack-workflows/tests/test_sync_keystone.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
Loading