From ed160f3810ff7459bad1404095fe0e85a836fd58 Mon Sep 17 00:00:00 2001 From: Alan Bounds Date: Tue, 25 Aug 2026 16:32:59 -0500 Subject: [PATCH 1/4] feat: add password_sync oslo_event handler (logging-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a modular password change detection handler that fires on baremetal.node.update.end events. When a node has extra.password_sync set to a recognized backend name (core, passwordsafe, 1password) and a device identifier (core_id or external_cmdb_id), the handler detects password changes via SHA-256 hash comparison against a value stored in driver_internal_info. Phase 1 (this commit): logging only — reports detected changes. Phase 2 (future): dispatch to backend for actual credential sync. Design: - Pluggable backends via PasswordSyncBackend ABC - extra['password_sync'] selects the backend by name - Change detection uses _uc_password_sync_hash in driver_internal_info - Password lookup: redfish_password > ipmi_password > any *_password AMMO-1309 --- .../main/openstack_oslo_event.py | 6 +- .../oslo_event/password_sync.py | 351 ++++++++++++++++++ 2 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 python/understack-workflows/understack_workflows/oslo_event/password_sync.py diff --git a/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py b/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py index eef6c8d75..95ac98aaf 100644 --- a/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py +++ b/python/understack-workflows/understack_workflows/main/openstack_oslo_event.py @@ -20,6 +20,7 @@ from understack_workflows.oslo_event import ironic_portgroup from understack_workflows.oslo_event import keystone_project from understack_workflows.oslo_event import nautobot_device_sync +from understack_workflows.oslo_event import password_sync logger = logging.getLogger(__name__) @@ -62,7 +63,10 @@ class NoEventHandlerError(Exception): "baremetal.portgroup.create.end": ironic_portgroup.handle_portgroup_create_update, "baremetal.portgroup.update.end": ironic_portgroup.handle_portgroup_create_update, "baremetal.portgroup.delete.end": ironic_portgroup.handle_portgroup_delete, - "baremetal.node.update.end": nautobot_device_sync.handle_node_event, + "baremetal.node.update.end": [ + nautobot_device_sync.handle_node_event, + password_sync.handle_node_update, + ], "baremetal.node.delete.end": nautobot_device_sync.handle_node_delete_event, "baremetal.node.provision_set.end": [ ironic_node.handle_provision_end, diff --git a/python/understack-workflows/understack_workflows/oslo_event/password_sync.py b/python/understack-workflows/understack_workflows/oslo_event/password_sync.py new file mode 100644 index 000000000..3c056413b --- /dev/null +++ b/python/understack-workflows/understack_workflows/oslo_event/password_sync.py @@ -0,0 +1,351 @@ +"""Detect BMC password changes and dispatch to sync backends. + +Listens for baremetal.node.update.end events and determines whether +a password field in driver_info has changed. When a change is detected +and the node is eligible, dispatches to the appropriate sync backend +based on the value of extra["password_sync"]. + +Supported backends (extensible): + - "core" → sync to Rackspace CORE via CTKAPI + - "passwordsafe" → (future) sync to PasswordSafe + - "1password" → (future) sync to 1Password + +A node is eligible when: + - extra["password_sync"] is set to a recognized backend name + - AND extra has a device identifier (core_id or external_cmdb_id) + +Values of "false", "disabled", or absent skip sync entirely. + +Change detection strategy: + The oslo notification always includes the full (masked) driver_info — + there is no "changed fields" metadata. To detect actual password + changes we compare the current password (fetched via the Ironic API) + against a hash stored in driver_internal_info after each successful + sync. + + The hash is stored as: + driver_internal_info["_uc_password_sync_hash"] = sha256(password) + + driver_internal_info is a freeform dict intended for internal + bookkeeping — it is not validated by Ironic drivers and is safe + for operator use with a namespaced key prefix. +""" + +import hashlib +import logging +from abc import ABC +from abc import abstractmethod +from typing import Any + +from openstack.connection import Connection +from pynautobot.core.api import Api as Nautobot + +logger = logging.getLogger(__name__) + +# Where we persist the last-synced password hash for change detection +_SYNC_HASH_KEY = "_uc_password_sync_hash" + +# Extra keys for device identification +_CORE_ID_KEY = "core_id" +_EXTERNAL_CMDB_ID_KEY = "external_cmdb_id" + +# Extra key that controls which backend to sync to +_PASSWORD_SYNC_KEY = "password_sync" + +# Values that explicitly disable sync +_DISABLED_VALUES = {"false", "disabled", "none", ""} + +# driver_info keys that hold passwords, ordered by priority +_PASSWORD_KEYS = ("redfish_password", "ipmi_password") + + +# --- Sync Backend Interface ------------------------------------------------- + + +class PasswordSyncBackend(ABC): + """Base class for password sync backends.""" + + @abstractmethod + def sync( + self, + device_id: str, + password: str, + node_uuid: str, + node_name: str, + ) -> bool: + """Push the password to the external system. + + Args: + device_id: External device identifier (core_id, etc.) + password: The new BMC password to sync. + node_uuid: Ironic node UUID for logging/correlation. + node_name: Ironic node name for logging/correlation. + + Returns: + True if the sync succeeded, False otherwise. + """ + + +class CoreBackend(PasswordSyncBackend): + """Sync BMC password to Rackspace CORE via CTKAPI. + + Phase 1 (current): logging only. + Phase 2 (future): actual CTKAPI call. + """ + + def sync( + self, + device_id: str, + password: str, + node_uuid: str, + node_name: str, + ) -> bool: + # TODO(phase2): Implement CTKAPI password update + # connector = Connector() + # connector.login() + # query = [{ + # "class": "Computer.Password", + # "load_arg": { + # "device": int(device_id), + # "password_type": 8, + # }, + # "method": "save", + # "keyword_args": {"password": password, "username": "root"}, + # }] + # connector.query(query) + logger.info( + "[password_sync:core] Would sync password for node %s (%s)" + " to CORE device %s", + node_uuid, + node_name, + device_id, + ) + return True + + +class PasswordSafeBackend(PasswordSyncBackend): + """Sync BMC password to PasswordSafe. (Future)""" + + def sync( + self, + device_id: str, + password: str, + node_uuid: str, + node_name: str, + ) -> bool: + logger.info( + "[password_sync:passwordsafe] Would sync password for " + "node %s (%s) to PasswordSafe device %s", + node_uuid, + node_name, + device_id, + ) + return False # Not implemented + + +class OnePasswordBackend(PasswordSyncBackend): + """Sync BMC password to 1Password. (Future)""" + + def sync( + self, + device_id: str, + password: str, + node_uuid: str, + node_name: str, + ) -> bool: + logger.info( + "[password_sync:1password] Would sync password for " + "node %s (%s) to 1Password device %s", + node_uuid, + node_name, + device_id, + ) + return False # Not implemented + + +# Registry of available backends +_BACKENDS: dict[str, PasswordSyncBackend] = { + "core": CoreBackend(), + "passwordsafe": PasswordSafeBackend(), + "1password": OnePasswordBackend(), +} + + +# --- Helpers ---------------------------------------------------------------- + + +def _extract_node_uuid(event_data: dict[str, Any]) -> str | None: + """Extract node UUID from a node CRUD event payload.""" + payload = event_data.get("payload", {}) + if isinstance(payload, dict): + ironic_data = payload.get("ironic_object.data", {}) + if isinstance(ironic_data, dict) and ironic_data.get("uuid"): + return ironic_data["uuid"] + return None + + +def _get_password_from_driver_info( + driver_info: dict[str, Any], +) -> tuple[str | None, str | None]: + """Find the first password value in driver_info. + + Returns (key_name, password_value) or (None, None). + Checks well-known keys first, then falls back to any key + ending in '_password'. + """ + for key in _PASSWORD_KEYS: + value = driver_info.get(key) + if value: + return key, value + + # Fallback: any key ending in _password + for key, value in driver_info.items(): + if key.endswith("_password") and value: + return key, value + + return None, None + + +def _hash_password(password: str) -> str: + """Return a hex SHA-256 hash of the password.""" + return hashlib.sha256(password.encode()).hexdigest() + + +def _get_backend(extra: dict[str, Any]) -> PasswordSyncBackend | None: + """Determine the sync backend from node extra. + + Returns None if sync is disabled or backend is unrecognized. + """ + raw_value = str(extra.get(_PASSWORD_SYNC_KEY, "")).strip().lower() + + if not raw_value or raw_value in _DISABLED_VALUES: + return None + + backend = _BACKENDS.get(raw_value) + if backend is None: + logger.warning( + "[password_sync] Unrecognized backend '%s'. " + "Available: %s", + raw_value, + list(_BACKENDS.keys()), + ) + return backend + + +def _get_device_id(extra: dict[str, Any]) -> str | None: + """Return the external device identifier, or None.""" + value = extra.get(_CORE_ID_KEY) or extra.get(_EXTERNAL_CMDB_ID_KEY) + return str(value) if value else None + + +# --- Event Handler ---------------------------------------------------------- + + +def handle_node_update( + conn: Connection, _nautobot: Nautobot, event_data: dict[str, Any] +) -> int: + """Handle baremetal.node.update.end for password change detection. + + Determines if the node's BMC password has changed and dispatches + to the configured sync backend. + """ + node_uuid = _extract_node_uuid(event_data) + if not node_uuid: + return 0 + + # Fetch the full node (notification masks secrets) + node = conn.baremetal.get_node(node_uuid) + if node is None: + logger.debug( + "[password_sync] Node %s not found, skipping", + node_uuid, + ) + return 0 + + extra = node.extra or {} + + # Determine backend; skip if disabled or absent + backend = _get_backend(extra) + if backend is None: + return 0 + + # Must have a device identifier to target + device_id = _get_device_id(extra) + if not device_id: + logger.debug( + "[password_sync] Node %s has password_sync enabled but " + "no core_id or external_cmdb_id, skipping", + node_uuid, + ) + return 0 + + # Extract password from driver_info + driver_info = node.driver_info or {} + password_key, password_value = _get_password_from_driver_info( + driver_info + ) + + if not password_value: + logger.debug( + "[password_sync] Node %s has no password in driver_info", + node_uuid, + ) + return 0 + + # Change detection: compare against stored hash + driver_internal = node.driver_internal_info or {} + stored_hash = driver_internal.get(_SYNC_HASH_KEY) + current_hash = _hash_password(password_value) + + if stored_hash == current_hash: + return 0 + + # --- Password change detected --- + node_name = node.name or node_uuid + backend_name = str(extra.get(_PASSWORD_SYNC_KEY, "")).strip().lower() + + if stored_hash is None: + logger.info( + "[password_sync:%s] Node %s (%s): initial password " + "detected (no prior hash). Device: %s, key: %s", + backend_name, + node_uuid, + node_name, + device_id, + password_key, + ) + else: + logger.info( + "[password_sync:%s] Node %s (%s): password CHANGED. " + "Device: %s, key: %s", + backend_name, + node_uuid, + node_name, + device_id, + password_key, + ) + + # Dispatch to backend + success = backend.sync( + device_id=device_id, + password=password_value, + node_uuid=node_uuid, + node_name=node_name, + ) + + # Persist hash only after successful sync + if success: + # TODO(phase2): uncomment when backends perform real writes + # conn.baremetal.update_node( + # node_uuid, + # [{"op": "add", + # "path": "/driver_internal_info/_uc_password_sync_hash", + # "value": current_hash}], + # ) + logger.debug( + "[password_sync:%s] Sync reported success for node %s", + backend_name, + node_uuid, + ) + + return 0 From 9a66c50e14c1c08d50e003b4d564decf4e487de6 Mon Sep 17 00:00:00 2001 From: Alan Bounds Date: Tue, 25 Aug 2026 16:56:32 -0500 Subject: [PATCH 2/4] style: apply ruff-format fixes to password_sync --- .../oslo_event/password_sync.py | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/python/understack-workflows/understack_workflows/oslo_event/password_sync.py b/python/understack-workflows/understack_workflows/oslo_event/password_sync.py index 3c056413b..b6a7df607 100644 --- a/python/understack-workflows/understack_workflows/oslo_event/password_sync.py +++ b/python/understack-workflows/understack_workflows/oslo_event/password_sync.py @@ -114,8 +114,7 @@ def sync( # }] # connector.query(query) logger.info( - "[password_sync:core] Would sync password for node %s (%s)" - " to CORE device %s", + "[password_sync:core] Would sync password for node %s (%s) to CORE device %s", node_uuid, node_name, device_id, @@ -134,8 +133,8 @@ def sync( node_name: str, ) -> bool: logger.info( - "[password_sync:passwordsafe] Would sync password for " - "node %s (%s) to PasswordSafe device %s", + "[password_sync:passwordsafe] Would sync password" + " for node %s (%s) to PasswordSafe device %s", node_uuid, node_name, device_id, @@ -154,8 +153,7 @@ def sync( node_name: str, ) -> bool: logger.info( - "[password_sync:1password] Would sync password for " - "node %s (%s) to 1Password device %s", + "[password_sync:1password] Would sync password for node %s (%s) to 1Password device %s", node_uuid, node_name, device_id, @@ -224,8 +222,7 @@ def _get_backend(extra: dict[str, Any]) -> PasswordSyncBackend | None: backend = _BACKENDS.get(raw_value) if backend is None: logger.warning( - "[password_sync] Unrecognized backend '%s'. " - "Available: %s", + "[password_sync] Unrecognized backend '%s'. Available: %s", raw_value, list(_BACKENDS.keys()), ) @@ -273,17 +270,14 @@ def handle_node_update( device_id = _get_device_id(extra) if not device_id: logger.debug( - "[password_sync] Node %s has password_sync enabled but " - "no core_id or external_cmdb_id, skipping", + "[password_sync] Node %s has password_sync enabled but no core_id or external_cmdb_id, skipping", node_uuid, ) return 0 # Extract password from driver_info driver_info = node.driver_info or {} - password_key, password_value = _get_password_from_driver_info( - driver_info - ) + password_key, password_value = _get_password_from_driver_info(driver_info) if not password_value: logger.debug( @@ -306,8 +300,7 @@ def handle_node_update( if stored_hash is None: logger.info( - "[password_sync:%s] Node %s (%s): initial password " - "detected (no prior hash). Device: %s, key: %s", + "[password_sync:%s] Node %s (%s): initial password detected (no prior hash). Device: %s, key: %s", backend_name, node_uuid, node_name, @@ -316,8 +309,7 @@ def handle_node_update( ) else: logger.info( - "[password_sync:%s] Node %s (%s): password CHANGED. " - "Device: %s, key: %s", + "[password_sync:%s] Node %s (%s): password CHANGED. Device: %s, key: %s", backend_name, node_uuid, node_name, From 20b87530519707363bb49cd7ddf5ef66b304ceb9 Mon Sep 17 00:00:00 2001 From: Alan Bounds Date: Tue, 25 Aug 2026 17:18:36 -0500 Subject: [PATCH 3/4] style: fix ruff S105, E501, D415 violations in password_sync --- .../oslo_event/password_sync.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/python/understack-workflows/understack_workflows/oslo_event/password_sync.py b/python/understack-workflows/understack_workflows/oslo_event/password_sync.py index b6a7df607..8225edb7f 100644 --- a/python/understack-workflows/understack_workflows/oslo_event/password_sync.py +++ b/python/understack-workflows/understack_workflows/oslo_event/password_sync.py @@ -50,7 +50,7 @@ _EXTERNAL_CMDB_ID_KEY = "external_cmdb_id" # Extra key that controls which backend to sync to -_PASSWORD_SYNC_KEY = "password_sync" +_PASSWORD_SYNC_KEY = "password_sync" # noqa: S105 # Values that explicitly disable sync _DISABLED_VALUES = {"false", "disabled", "none", ""} @@ -114,7 +114,8 @@ def sync( # }] # connector.query(query) logger.info( - "[password_sync:core] Would sync password for node %s (%s) to CORE device %s", + "[password_sync:core] Would sync password for" + " node %s (%s) to CORE device %s", node_uuid, node_name, device_id, @@ -123,7 +124,7 @@ def sync( class PasswordSafeBackend(PasswordSyncBackend): - """Sync BMC password to PasswordSafe. (Future)""" + """Sync BMC password to PasswordSafe. (Future.)""" def sync( self, @@ -143,7 +144,7 @@ def sync( class OnePasswordBackend(PasswordSyncBackend): - """Sync BMC password to 1Password. (Future)""" + """Sync BMC password to 1Password. (Future.)""" def sync( self, @@ -153,7 +154,8 @@ def sync( node_name: str, ) -> bool: logger.info( - "[password_sync:1password] Would sync password for node %s (%s) to 1Password device %s", + "[password_sync:1password] Would sync password" + " for node %s (%s) to 1Password device %s", node_uuid, node_name, device_id, @@ -270,7 +272,8 @@ def handle_node_update( device_id = _get_device_id(extra) if not device_id: logger.debug( - "[password_sync] Node %s has password_sync enabled but no core_id or external_cmdb_id, skipping", + "[password_sync] Node %s has password_sync enabled" + " but no core_id or external_cmdb_id, skipping", node_uuid, ) return 0 @@ -300,7 +303,8 @@ def handle_node_update( if stored_hash is None: logger.info( - "[password_sync:%s] Node %s (%s): initial password detected (no prior hash). Device: %s, key: %s", + "[password_sync:%s] Node %s (%s): initial password" + " detected (no prior hash). Device: %s, key: %s", backend_name, node_uuid, node_name, From cbe41ba926ce17d17647b3e3b51d4e92acc27ae7 Mon Sep 17 00:00:00 2001 From: Alan Bounds Date: Tue, 25 Aug 2026 17:24:04 -0500 Subject: [PATCH 4/4] style: fix D415 docstring ending violations --- .../understack_workflows/oslo_event/password_sync.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/python/understack-workflows/understack_workflows/oslo_event/password_sync.py b/python/understack-workflows/understack_workflows/oslo_event/password_sync.py index 8225edb7f..092f9316a 100644 --- a/python/understack-workflows/understack_workflows/oslo_event/password_sync.py +++ b/python/understack-workflows/understack_workflows/oslo_event/password_sync.py @@ -124,7 +124,10 @@ def sync( class PasswordSafeBackend(PasswordSyncBackend): - """Sync BMC password to PasswordSafe. (Future.)""" + """Sync BMC password to PasswordSafe. + + Not yet implemented. + """ def sync( self, @@ -144,7 +147,10 @@ def sync( class OnePasswordBackend(PasswordSyncBackend): - """Sync BMC password to 1Password. (Future.)""" + """Sync BMC password to 1Password. + + Not yet implemented. + """ def sync( self,