diff --git a/changelog.d/20260817_124358_undersync_token_removal.md b/changelog.d/20260817_124358_undersync_token_removal.md new file mode 100644 index 000000000..5143d5e4e --- /dev/null +++ b/changelog.d/20260817_124358_undersync_token_removal.md @@ -0,0 +1,9 @@ +### Deprecations and removals + +The `undersync-switch` workflow now authenticates to Undersync with the +OpenStack credentials in the `baremetal-manage` Secret instead of a static +bearer token, matching how the Neutron mechanism driver already calls Undersync. +Nothing in UnderStack reads the `undersync-token` Secret any more, so you can +remove it from your deploy repo. + +The workflow also no longer mounts `nautobot-token`, which it never used. diff --git a/docs/deploy-guide/components/argo-events.md b/docs/deploy-guide/components/argo-events.md index 42b516bfb..7fb441a16 100644 --- a/docs/deploy-guide/components/argo-events.md +++ b/docs/deploy-guide/components/argo-events.md @@ -46,7 +46,6 @@ Required or commonly required items: - `core-creds` Secret: Provide `username` and `password` keys for any shared automation account used by event-driven jobs. - `bmc-master` Secret: Provide a `key` value when hardware-management workflows need a master credential. - `bmc-legacy-passwords` Secret: Provide a `passwords` key when workflows still need a flat password bundle. -- `undersync-token` Secret: Provide a `token` key if workflows call the undersync API. - `deploy-repo-auth` Secret: Provide `ssh-privatekey` and `known_hosts` so workflows can clone or update deployment content. - `dockerconfigjson-github-com` Secret: Provide `.dockerconfigjson` when workflow images are pulled from a private registry. diff --git a/python/understack-workflows/tests/test_undersync_client.py b/python/understack-workflows/tests/test_undersync_client.py new file mode 100644 index 000000000..0b9c4dc97 --- /dev/null +++ b/python/understack-workflows/tests/test_undersync_client.py @@ -0,0 +1,73 @@ +from unittest.mock import Mock + +import pytest + +from understack_workflows.undersync.client import Undersync + +API_URL = "http://undersync.example.com" + + +@pytest.fixture +def session(): + session = Mock() + session.post.return_value.status_code = 200 + return session + + +@pytest.fixture +def undersync(session): + return Undersync(session, api_url=API_URL) + + +@pytest.mark.parametrize( + ("method", "action"), + [("sync", "sync"), ("dry_run", "dry-run"), ("force", "force")], +) +def test_posts_to_action_endpoint(undersync, session, method, action): + response = getattr(undersync, method)("a1-1-network") + + session.post.assert_called_once_with( + f"{API_URL}/v1/vlan-group/a1-1-network/{action}", timeout=undersync.timeout + ) + assert response is session.post.return_value + + +@pytest.mark.parametrize( + ("kwargs", "action"), + [ + ({}, "sync"), + ({"force": True}, "force"), + ({"dry_run": True}, "dry-run"), + # dry_run wins so that a preview never pushes to the switches + ({"force": True, "dry_run": True}, "dry-run"), + ], +) +def test_sync_devices_dispatch(undersync, session, kwargs, action): + undersync.sync_devices("a1-1-network", **kwargs) + + session.post.assert_called_once_with( + f"{API_URL}/v1/vlan-group/a1-1-network/{action}", timeout=undersync.timeout + ) + + +def test_physical_network_is_escaped(undersync, session): + undersync.sync("weird/name space") + + session.post.assert_called_once_with( + f"{API_URL}/v1/vlan-group/weird%2Fname%20space/sync", timeout=undersync.timeout + ) + + +def test_raises_for_status(undersync, session): + session.post.return_value.raise_for_status.side_effect = RuntimeError("boom") + + with pytest.raises(RuntimeError): + undersync.sync("a1-1-network") + + +def test_each_request_goes_through_the_session(undersync, session): + """keystoneauth1 handles token refresh, so we must not cache a token.""" + undersync.sync("a1-1-network") + undersync.sync("a1-2-network") + + assert session.post.call_count == 2 diff --git a/python/understack-workflows/understack_workflows/main/undersync_switch.py b/python/understack-workflows/understack_workflows/main/undersync_switch.py index b2c336c15..9c92b9e3a 100644 --- a/python/understack-workflows/understack_workflows/main/undersync_switch.py +++ b/python/understack-workflows/understack_workflows/main/undersync_switch.py @@ -4,18 +4,13 @@ import sys from understack_workflows.helpers import boolean_args -from understack_workflows.helpers import credential from understack_workflows.helpers import setup_logger +from understack_workflows.openstack.client import get_session from understack_workflows.undersync.client import Undersync def call_undersync(args): - undersync_token = credential("undersync", "token") - if not undersync_token: - logger.error("Please provide auth token for Undersync.") - sys.exit(1) - - undersync = Undersync(undersync_token) + undersync = Undersync(get_session()) try: logger.debug( diff --git a/python/understack-workflows/understack_workflows/openstack/client.py b/python/understack-workflows/understack_workflows/openstack/client.py index ec346e1bf..837cb6230 100644 --- a/python/understack-workflows/understack_workflows/openstack/client.py +++ b/python/understack-workflows/understack_workflows/openstack/client.py @@ -7,6 +7,7 @@ from ironicclient.client import Client as IronicClient from ironicclient.client import get_client as _get_ironic_client +from keystoneauth1.session import Session from openstack import config as _os_config from openstack.connection import Connection @@ -33,6 +34,11 @@ def _get_os_cloud_region(cloud=None, region_name=""): ) +def get_session(cloud=None, region_name="") -> Session: + """Returns a keystoneauth1 Session based on our clouds.yaml.""" + return _get_os_cloud_region(cloud, region_name).get_session() + + def get_openstack_client(cloud=None, region_name="") -> Connection: """Returns an OpenStackSDK Connection based on our clouds.yaml.""" cloud_region = _get_os_cloud_region(cloud, region_name) @@ -56,4 +62,5 @@ def get_ironic_client(cloud=None, region_name="") -> IronicClient: # type: igno __all__ = [ "get_ironic_client", "get_openstack_client", + "get_session", ] diff --git a/python/understack-workflows/understack_workflows/undersync/client.py b/python/understack-workflows/understack_workflows/undersync/client.py index c378ceb6a..f277c2f43 100644 --- a/python/understack-workflows/understack_workflows/undersync/client.py +++ b/python/understack-workflows/understack_workflows/undersync/client.py @@ -1,18 +1,24 @@ -from functools import cached_property from urllib.parse import quote import requests +from keystoneauth1.session import Session class Undersync: def __init__( self, - auth_token: str, + session: Session, api_url="http://undersync.undersync.svc.cluster.local:8080", + timeout: int = 90, ) -> None: - """Simple client for Undersync.""" - self.token = auth_token + """Simple client for Undersync. + + Authenticates with the OpenStack credentials from the supplied + keystoneauth1 session, which handles token refresh transparently. + """ + self.session = session self.api_url = api_url + self.timeout = timeout def sync_devices(self, physical_network: str, force=False, dry_run=False): if dry_run: @@ -22,29 +28,19 @@ def sync_devices(self, physical_network: str, force=False, dry_run=False): else: return self.sync(physical_network) - @cached_property - def client(self): - session = requests.Session() - session.headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.token}", - } - return session - - def sync(self, physical_network: str) -> requests.Response: + def _post(self, action: str, physical_network: str) -> requests.Response: physnet = quote(physical_network, safe="") - response = self.client.post(f"{self.api_url}/v1/vlan-group/{physnet}/sync") + response = self.session.post( + f"{self.api_url}/v1/vlan-group/{physnet}/{action}", timeout=self.timeout + ) response.raise_for_status() return response + def sync(self, physical_network: str) -> requests.Response: + return self._post("sync", physical_network) + def dry_run(self, physical_network: str) -> requests.Response: - physnet = quote(physical_network, safe="") - response = self.client.post(f"{self.api_url}/v1/vlan-group/{physnet}/dry-run") - response.raise_for_status() - return response + return self._post("dry-run", physical_network) def force(self, physical_network: str) -> requests.Response: - physnet = quote(physical_network, safe="") - response = self.client.post(f"{self.api_url}/v1/vlan-group/{physnet}/force") - response.raise_for_status() - return response + return self._post("force", physical_network) diff --git a/workflows/argo-events/workflowtemplates/undersync-switch.yaml b/workflows/argo-events/workflowtemplates/undersync-switch.yaml index 736fc45a0..c736a5c6b 100644 --- a/workflows/argo-events/workflowtemplates/undersync-switch.yaml +++ b/workflows/argo-events/workflowtemplates/undersync-switch.yaml @@ -63,22 +63,16 @@ spec: - --force - "{{inputs.parameters.force}}" volumeMounts: - - mountPath: /etc/nb-token/ - name: nb-token - readOnly: true - - mountPath: /etc/undersync/ - name: undersync-token + - mountPath: /etc/openstack + name: baremetal-manage readOnly: true env: - - name: NAUTOBOT_URL - valueFrom: - secretKeyRef: - name: nautobot-token - key: url + - name: OS_CLOUD + value: understack volumes: - - name: nb-token - secret: - secretName: nautobot-token - - name: undersync-token + - name: baremetal-manage secret: - secretName: undersync-token + secretName: baremetal-manage + items: + - key: clouds.yaml + path: clouds.yaml