diff --git a/Dockerfile b/Dockerfile index d317bd48d..9734df7cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,16 +41,18 @@ LABEL org.opencontainers.image.vendor="Metal3-io" ARG PKGS_LIST=main-packages-list.txt ARG EXTRA_PKGS_LIST -ARG PATCH_LIST # build arguments for source build customization ARG UPPER_CONSTRAINTS_FILE=upper-constraints.txt -ARG IRONIC_SOURCE=ea2bd759b32a09ec71477cec9194df7e934f5b22 # bugfix/33.0 +ARG IRONIC_SOURCE=a26e740d8413f35fb8014a899ad704207374369a # stable/2026.1 ARG SUSHY_SOURCE +ARG GIT_HOST=https://opendev.org +ENV GIT_HOST=${GIT_HOST} COPY sources /sources/ +COPY patches /tmp/patches/ COPY ${UPPER_CONSTRAINTS_FILE} ironic-packages-list ${PKGS_LIST} \ - ${EXTRA_PKGS_LIST:-$PKGS_LIST} ${PATCH_LIST:-$PKGS_LIST} \ + ${EXTRA_PKGS_LIST:-$PKGS_LIST} \ /tmp/ COPY ironic-config/inspector.ipxe.j2 ironic-config/httpd-ironic-api.conf.j2 \ ironic-config/ipxe_config.template ironic-config/dnsmasq.conf.j2 \ diff --git a/README.md b/README.md index 2235f0da5..454acf246 100644 --- a/README.md +++ b/README.md @@ -199,29 +199,21 @@ filename as far as it's in the container context. ## Apply project patches to the images during build -When building the image, it is possible to specify a patch of one or more -upstream projects to apply to the image using the **PATCH_LIST** argument in -the cli command, for example: - -```bash -podman build -t ironic-image -f Dockerfile --build-arg PATCH_LIST=my-patch-list -``` - -The **PATCH_LIST** argument is a path to a file under the image context. -Its format is a simple text file that contains references to upstream patches -for the ironic projects. -Each line of the file is in the form: - **project_dir refspec (git_host)** -where: - -- **project_dir** is the last part of the project url including the - organization, for example for ironic is _openstack/ironic_ -- **refspec** is the gerrit refspec of the patch we want to test, for example if - you want to apply the patch at - - the refspec will be _refs/changes/84/800084/22_ - Using multiple refspecs is convenient in case we need to test patches that - are connected to each other, either on the same project or on different - projects. -- **git_host** (optional) is the git host from which the project will be cloned. - If unset, `https://opendev.org` is used. +Place patch files under `patches//` in the image build context. The +build copies that tree to `/tmp/patches` and, after rendering +`ironic-packages-list` with Jinja2, replaces any matching requirement with +`ironic @ file:///sources/` (and similarly for other projects): the tree +is cloned from `https://opendev.org/.git` (override the host with the +**GIT_HOST** build-arg if needed), patches are applied with `git apply` in +alphabetical order per project, and pip installs from that directory. A plain +`file://` URL is used because `git+file://` makes pip run `git clone`, which +only copies committed objects and would skip uncommitted `git apply` changes. + +- **Single path segment** (for example `patches/ironic/`): clones + `openstack/` (same as `openstack/ironic` for `patches/ironic/`). +- **Two segments** (for example `patches/openstack/ironic/`): clones that full + path as the Git project. + +The branch or commit comes from the rendered requirement line (for example +`ironic @ git+https://opendev.org/openstack/ironic@`). If the line has no +`@ref`, the repository default branch is used. diff --git a/patch-image.sh b/patch-image.sh index 61e02b6c0..36780447a 100755 --- a/patch-image.sh +++ b/patch-image.sh @@ -1,32 +1,126 @@ #!/usr/bin/bash -set -ex +set -euxo pipefail -PATCH_FILE="/tmp/${PATCH_LIST}" -VARS="PROJECT REFSPEC GIT_HOST" +# Apply patches from patches//*.patch before pip installs from +# ironic-packages-list-final. For each project with patch files, the matching +# requirement line is replaced with file:///sources/ (not git+file: pip +# would clone from .git and omit uncommitted git-apply changes). -declare -a REQS=( - git-core - python3.12-pip -) +PATCHES_ROOT="/tmp/patches" +IRONIC_PKG_LIST_FINAL="/tmp/ironic-packages-list-final" -dnf install -y "${REQS[@]}" +[[ -d "${PATCHES_ROOT}" ]] || exit 0 -while IFS= read -r line; do - # shellcheck disable=SC2086,SC2229 - IFS=' ' read -r $VARS <<< "$line" - PROJ_NAME=$(echo "$PROJECT" | cut -d "/" -f2) - PROJ_URL="${GIT_HOST:-"https://opendev.org"}/$PROJECT" +export PATCHES_ROOT IRONIC_PKG_LIST_FINAL +export GIT_HOST="${GIT_HOST:-https://opendev.org}" - cd /tmp - git clone "$PROJ_URL" - cd "$PROJ_NAME" - git fetch "$PROJ_URL" "$REFSPEC" - git checkout FETCH_HEAD +python3.12 <<'PY' +import os +import re +import shutil +import subprocess +import sys - SKIP_GENERATE_AUTHORS=1 SKIP_WRITE_GIT_CHANGELOG=1 python3.12 setup.py sdist - python3.12 -m pip install --prefix /usr dist/*.tar.gz -done < "$PATCH_FILE" +PATCHES_ROOT = os.environ["PATCHES_ROOT"] +FINAL = os.environ["IRONIC_PKG_LIST_FINAL"] +GIT_BASE = os.environ.get("GIT_HOST", "https://opendev.org").rstrip("/") +SOURCES = "/sources" -dnf remove -y "${REQS[@]}" -cd / +def git(*args: str, cwd: str | None = None) -> None: + subprocess.run(["git", *args], cwd=cwd, check=True) + + +def project_for_rel(rel: str) -> str: + return rel if "/" in rel else f"openstack/{rel}" + + +def line_matches_project(line: str, project: str, short: str) -> bool: + # Patched installs use a plain file:// URL so pip uses the working tree. + # git+file:// would make pip git-clone the repo, which only copies commits + # and drops uncommitted changes from git apply. + if f"file:///sources/{short}" in line: + return True + if f"git+file:///sources/{short}" in line: + return True + boundary = re.compile(re.escape("/" + project) + r"(?=@|[#\"'\s]|$)") + return boundary.search(line) is not None + + +def parse_pkg_url(line: str, short: str) -> tuple[str, str]: + if " @ " in line: + pkg, url_spec = line.split(" @ ", 1) + return pkg.strip(), url_spec.strip() + return short, line.strip() + + +def extract_ref(url_spec: str, project: str) -> str | None: + mark = "/" + project + if mark not in url_spec: + return None + i = url_spec.index(mark) + len(mark) + if i < len(url_spec) and url_spec[i] == "@": + return url_spec[i + 1 :].split("#")[0].strip() + return None + + +# Collect patch files grouped by project directory (relative to PATCHES_ROOT). +by_rel: dict[str, list[str]] = {} +for root, _dirs, files in os.walk(PATCHES_ROOT): + for name in sorted(files): + if not name.endswith((".patch", ".diff")): + continue + path = os.path.join(root, name) + rel = os.path.relpath(os.path.dirname(path), PATCHES_ROOT) + if rel == ".": + print( + f"WARNING: ignore patch at root of {PATCHES_ROOT}: {path}", + file=sys.stderr, + ) + continue + by_rel.setdefault(rel, []).append(path) + +for rel in by_rel: + by_rel[rel] = sorted(by_rel[rel]) + +if not by_rel: + sys.exit(0) + +with open(FINAL) as f: + lines = f.read().splitlines() + +for rel in sorted(by_rel.keys()): + project = project_for_rel(rel) + short = project.split("/")[-1] + match_idx = None + for i, line in enumerate(lines): + if line_matches_project(line, project, short): + match_idx = i + break + if match_idx is None: + print( + f"WARNING: no requirement line for {project}; skip patches under {rel!r}", + file=sys.stderr, + ) + continue + + line = lines[match_idx] + pkg, url_spec = parse_pkg_url(line, short) + ref = extract_ref(url_spec, project) + + dest = os.path.join(SOURCES, short) + shutil.rmtree(dest, ignore_errors=True) + os.makedirs(SOURCES, exist_ok=True) + git("clone", f"{GIT_BASE}/{project}.git", dest) + if ref: + git("fetch", "origin", ref, cwd=dest) + git("checkout", "FETCH_HEAD", cwd=dest) + + for patch_path in by_rel[rel]: + git("apply", patch_path, cwd=dest) + + lines[match_idx] = f"{pkg} @ file:///sources/{short}" + +with open(FINAL, "w") as f: + f.write("\n".join(lines) + "\n") +PY diff --git a/patches/ironic/0001-Always-get-actual-power-state-from-redfish-API.patch b/patches/ironic/0001-Always-get-actual-power-state-from-redfish-API.patch new file mode 100644 index 000000000..1dda40665 --- /dev/null +++ b/patches/ironic/0001-Always-get-actual-power-state-from-redfish-API.patch @@ -0,0 +1,493 @@ +From be63c571f6cc24e4b2355c4af974d54e8321ca21 Mon Sep 17 00:00:00 2001 +From: Vasyl Saienko +Date: Sat, 4 Apr 2026 14:51:48 +0300 +Subject: [PATCH] Always get actual power state from redfish API + +Do not rely on any cache values or transitional states, +get actual state from redfish and ensure it matches +with target before say we ready. +--- + ironic/drivers/modules/redfish/power.py | 74 +++++++- + .../drivers/modules/redfish/test_power.py | 172 ++++++++---------- + ...ing-off-transitional-c2ffdd7eacff24b6.yaml | 15 ++ + 3 files changed, 156 insertions(+), 105 deletions(-) + create mode 100644 releasenotes/notes/redfish-powering-off-transitional-c2ffdd7eacff24b6.yaml + +diff --git a/ironic/drivers/modules/redfish/power.py b/ironic/drivers/modules/redfish/power.py +index 8f977cc38..d964eced5 100644 +--- a/ironic/drivers/modules/redfish/power.py ++++ b/ironic/drivers/modules/redfish/power.py +@@ -16,13 +16,14 @@ + import time + + from oslo_log import log ++from oslo_service import loopingcall + import sushy + + from ironic.common import exception + from ironic.common.i18n import _ + from ironic.common import states + from ironic.conductor import task_manager +-from ironic.conductor import utils as cond_utils ++from ironic.conf import CONF + from ironic.drivers import base + from ironic.drivers.modules.redfish import management as redfish_mgmt + from ironic.drivers.modules.redfish import utils as redfish_utils +@@ -33,7 +34,9 @@ GET_POWER_STATE_MAP = { + sushy.SYSTEM_POWER_STATE_ON: states.POWER_ON, + sushy.SYSTEM_POWER_STATE_POWERING_ON: states.POWER_ON, + sushy.SYSTEM_POWER_STATE_OFF: states.POWER_OFF, +- sushy.SYSTEM_POWER_STATE_POWERING_OFF: states.POWER_OFF ++ # PoweringOff is transitional; mapping it to POWER_OFF makes Ironic think ++ # the host is already off and can issue power-on while the BMC is still ++ # shutting down (some BMCs then accept but ignore ResetType On). + } + + SET_POWER_STATE_MAP = { +@@ -51,6 +54,58 @@ TARGET_STATE_MAP = { + } + + ++def _redfish_target_power_state_reached( ++ sushy_power_state, target_ironic_state): ++ """Whether Redfish power state satisfies the requested Ironic target. ++ ++ Uses raw Redfish values so we do not treat transitional states (e.g. ++ ``PoweringOff``) as a stable power off. ++ """ ++ if target_ironic_state == states.POWER_OFF: ++ return sushy_power_state == sushy.SYSTEM_POWER_STATE_OFF ++ if target_ironic_state == states.POWER_ON: ++ return sushy_power_state in ( ++ sushy.SYSTEM_POWER_STATE_ON, ++ sushy.SYSTEM_POWER_STATE_POWERING_ON, ++ ) ++ return False ++ ++ ++def _wait_redfish_system_power_state(task, system, target_ironic_state, ++ timeout=None): ++ """Poll the BMC until ``target_ironic_state`` is reached. ++ ++ Each iteration calls :meth:`system.refresh` so the observed state is ++ fetched from Redfish, not a stale in-memory representation. ++ """ ++ retry_timeout = timeout or CONF.conductor.power_state_change_timeout ++ ++ def _wait(): ++ try: ++ system.refresh() ++ except Exception as err: ++ LOG.debug('Redfish System.refresh while waiting for %(target)s ' ++ 'on node %(node)s: %(err)s', ++ {'target': target_ironic_state, 'node': task.node.uuid, ++ 'err': err}) ++ return False ++ if _redfish_target_power_state_reached(system.power_state, ++ target_ironic_state): ++ raise loopingcall.LoopingCallDone() ++ return False ++ ++ try: ++ timer = loopingcall.BackOffLoopingCall(_wait) ++ timer.start(initial_delay=1, timeout=retry_timeout).wait() ++ except loopingcall.LoopingCallTimeOut: ++ LOG.error('Timed out after %(retry_timeout)s secs waiting for ' ++ '%(state)s on node %(node_id)s (Redfish refresh loop).', ++ {'retry_timeout': retry_timeout, ++ 'state': target_ironic_state, ++ 'node_id': task.node.uuid}) ++ raise exception.PowerStateFailure(pstate=target_ironic_state) ++ ++ + def _set_power_state(task, system, power_state, timeout=None): + """An internal helper to set a power state on the system. + +@@ -62,14 +117,18 @@ def _set_power_state(task, system, power_state, timeout=None): + :raises: RedfishConnectionError when it fails to connect to Redfish + :raises: RedfishError on an error from the Sushy library + """ +- system.reset_system(SET_POWER_STATE_MAP.get(power_state)) ++ reset_type = SET_POWER_STATE_MAP.get(power_state) + target_state = TARGET_STATE_MAP.get(power_state, power_state) ++ timeout_val = timeout or CONF.conductor.power_state_change_timeout ++ ++ system.reset_system(reset_type) + if power_state == states.REBOOT: + LOG.debug('Waiting 15 seconds to give the node %s a chance to power ' + 'off before checking its power state', task.node.uuid) + time.sleep(15) +- cond_utils.node_wait_for_power_state(task, target_state, +- timeout=timeout) ++ ++ _wait_redfish_system_power_state(task, system, target_state, ++ timeout=timeout_val) + + + class RedfishPower(base.PowerInterface): +@@ -94,7 +153,10 @@ class RedfishPower(base.PowerInterface): + """Get the current power state of the task's node. + + :param task: a TaskManager instance containing the node to act on. +- :returns: a power state. One of :mod:`ironic.common.states`. ++ :returns: a power state, one of :mod:`ironic.common.states`, or ++ :data:`ironic.common.states.NOSTATE` (``None``) when the BMC ++ reports a transitional or unknown Redfish power state ++ (e.g. ``PoweringOff``). + :raises: InvalidParameterValue on malformed parameter(s) + :raises: MissingParameterValue on missing parameter(s) + :raises: RedfishConnectionError when it fails to connect to Redfish +diff --git a/ironic/tests/unit/drivers/modules/redfish/test_power.py b/ironic/tests/unit/drivers/modules/redfish/test_power.py +index 0e6d197be..625df9b93 100644 +--- a/ironic/tests/unit/drivers/modules/redfish/test_power.py ++++ b/ironic/tests/unit/drivers/modules/redfish/test_power.py +@@ -13,11 +13,10 @@ + # License for the specific language governing permissions and limitations + # under the License. + +-import time + from unittest import mock + +-from oslo_service import loopingcall as lc + import sushy ++from oslo_service import loopingcall as lc + + from ironic.common import exception + from ironic.common import states +@@ -32,6 +31,25 @@ from ironic.tests.unit.objects import utils as obj_utils + INFO_DICT = db_utils.get_test_redfish_info() + + ++def _system_mock_with_refresh_progression(power_states): ++ """Mock Redfish System; each refresh() advances along power_states. ++ ++ Mirrors production behavior where :meth:`_wait_redfish_system_power_state` ++ calls ``system.refresh()`` then reads ``system.power_state``. ++ """ ++ m = mock.Mock() ++ seq = list(power_states) ++ idx = [0] ++ ++ def refresh(): ++ idx[0] = min(idx[0] + 1, len(seq) - 1) ++ m.power_state = seq[idx[0]] ++ ++ m.power_state = seq[0] ++ m.refresh.side_effect = refresh ++ return m ++ ++ + class RedfishPowerTestCase(db_base.DbTestCase): + + def setUp(self): +@@ -67,7 +85,7 @@ class RedfishPowerTestCase(db_base.DbTestCase): + (sushy.SYSTEM_POWER_STATE_ON, states.POWER_ON), + (sushy.SYSTEM_POWER_STATE_POWERING_ON, states.POWER_ON), + (sushy.SYSTEM_POWER_STATE_OFF, states.POWER_OFF), +- (sushy.SYSTEM_POWER_STATE_POWERING_OFF, states.POWER_OFF) ++ (sushy.SYSTEM_POWER_STATE_POWERING_OFF, None), + ] + for current, expected in expected_values: + mock_get_system.return_value = mock.Mock(power_state=current) +@@ -76,13 +94,12 @@ class RedfishPowerTestCase(db_base.DbTestCase): + mock_get_system.assert_called_once_with(task.node) + mock_get_system.reset_mock() + +- @mock.patch.object(lc.BackOffLoopingCall, '_sleep', autospec=True) + @mock.patch('time.sleep', autospec=True) + @mock.patch.object(redfish_mgmt.RedfishManagement, 'restore_boot_device', + autospec=True) + @mock.patch.object(redfish_utils, 'get_system', autospec=True) + def test_set_power_state(self, mock_get_system, mock_restore_bootdev, +- mock_sleep, mock_lc_sleep): ++ mock_sleep): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + expected_values = [ +@@ -103,20 +120,18 @@ class RedfishPowerTestCase(db_base.DbTestCase): + final = sushy.SYSTEM_POWER_STATE_ON + transient = sushy.SYSTEM_POWER_STATE_OFF + +- system_result = [ +- mock.Mock(power_state=transient) +- ] * 3 + [mock.Mock(power_state=final)] +- mock_get_system.side_effect = system_result ++ progression = [transient] * 4 + [final] ++ sys_m = _system_mock_with_refresh_progression(progression) ++ mock_get_system.return_value = sys_m + + task.driver.power.set_power_state(task, target) + + # Asserts +- system_result[0].reset_system.assert_called_once_with(expected) +- mock_get_system.assert_called_with(task.node) +- self.assertEqual(4, mock_get_system.call_count) ++ sys_m.reset_system.assert_called_once_with(expected) ++ mock_get_system.assert_called_once_with(task.node) + if restore_bootdev: + mock_restore_bootdev.assert_called_once_with( +- task.driver.management, task, system_result[0]) ++ task.driver.management, task, sys_m) + else: + self.assertFalse(mock_restore_bootdev.called) + if sleeps: +@@ -129,11 +144,9 @@ class RedfishPowerTestCase(db_base.DbTestCase): + mock_restore_bootdev.reset_mock() + mock_sleep.reset_mock() + +- @mock.patch.object(lc.BackOffLoopingCall, '_sleep', autospec=True) + @mock.patch('time.sleep', autospec=True) + @mock.patch.object(redfish_utils, 'get_system', autospec=True) +- def test_set_power_state_not_reached(self, mock_get_system, mock_sleep, +- mock_lc_sleep): ++ def test_set_power_state_not_reached(self, mock_get_system, mock_sleep): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.config(power_state_change_timeout=2, group='conductor') +@@ -158,7 +171,7 @@ class RedfishPowerTestCase(db_base.DbTestCase): + + # Asserts + fake_system.reset_system.assert_called_once_with(expected) +- mock_get_system.assert_called_with(task.node) ++ mock_get_system.assert_called_once_with(task.node) + + # Reset mocks + mock_get_system.reset_mock() +@@ -366,136 +379,102 @@ class RedfishPowerTestCase(db_base.DbTestCase): + log_msg = mock_log.call_args[0][0] + self.assertIn('Failed to refresh system state', log_msg) + +- @mock.patch.object(lc.BackOffLoopingCall, '_sleep', autospec=True) + @mock.patch.object(redfish_mgmt.RedfishManagement, 'restore_boot_device', + autospec=True) + @mock.patch.object(redfish_utils, 'get_system', autospec=True) + def test_reboot_from_power_off(self, mock_get_system, +- mock_restore_bootdev, mock_lc_sleep): ++ mock_restore_bootdev): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: +- system_result = [ +- # Initial state +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_OFF), +- # Transient state - still powered off +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_OFF), +- # Final state - down powering off +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_ON) +- ] +- mock_get_system.side_effect = system_result ++ off = sushy.SYSTEM_POWER_STATE_OFF ++ on = sushy.SYSTEM_POWER_STATE_ON ++ sys_m = _system_mock_with_refresh_progression([off, off, off, on]) ++ mock_get_system.return_value = sys_m + + task.driver.power.reboot(task) + + # Asserts +- system_result[0].reset_system.assert_called_once_with( +- sushy.RESET_ON) +- mock_get_system.assert_called_with(task.node) +- self.assertEqual(3, mock_get_system.call_count) ++ sys_m.reset_system.assert_called_once_with(sushy.RESET_ON) ++ mock_get_system.assert_called_once_with(task.node) + mock_restore_bootdev.assert_called_once_with( +- task.driver.management, task, system_result[0]) ++ task.driver.management, task, sys_m) + +- @mock.patch.object(lc.BackOffLoopingCall, '_sleep', autospec=True) + @mock.patch.object(redfish_mgmt.RedfishManagement, 'restore_boot_device', + autospec=True) + @mock.patch.object(redfish_utils, 'get_system', autospec=True) + def test_reboot_from_power_off_with_disable_power_off( +- self, mock_get_system, mock_restore_bootdev, mock_lc_sleep): ++ self, mock_get_system, mock_restore_bootdev): + # NOTE(dtantsur): if a node with disable_power_off is powered off, we + # probably cannot do anything about it. This unit test is only here + # for consistent coverage. + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node.disable_power_off = True +- system_result = [ +- # Initial state +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_OFF), +- # Transient state - still powered off +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_OFF), +- # Final state - down powering off +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_ON) +- ] +- mock_get_system.side_effect = system_result ++ off = sushy.SYSTEM_POWER_STATE_OFF ++ on = sushy.SYSTEM_POWER_STATE_ON ++ sys_m = _system_mock_with_refresh_progression([off, off, off, on]) ++ mock_get_system.return_value = sys_m + + task.driver.power.reboot(task) + + # Asserts +- system_result[0].reset_system.assert_called_once_with( +- sushy.RESET_ON) +- mock_get_system.assert_called_with(task.node) +- self.assertEqual(3, mock_get_system.call_count) ++ sys_m.reset_system.assert_called_once_with(sushy.RESET_ON) ++ mock_get_system.assert_called_once_with(task.node) + mock_restore_bootdev.assert_called_once_with( +- task.driver.management, task, system_result[0]) ++ task.driver.management, task, sys_m) + +- @mock.patch.object(lc.BackOffLoopingCall, '_sleep', autospec=True) + @mock.patch.object(redfish_mgmt.RedfishManagement, 'restore_boot_device', + autospec=True) + @mock.patch.object(redfish_utils, 'get_system', autospec=True) +- def test_reboot_from_power_on(self, mock_get_system, mock_restore_bootdev, +- mock_lc_sleep): ++ def test_reboot_from_power_on(self, mock_get_system, mock_restore_bootdev): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: +- system_result = [ +- # Initial state +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_ON), +- # Transient state - powering off +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_OFF), +- # Final state - down powering off +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_ON) +- ] +- mock_get_system.side_effect = system_result ++ on = sushy.SYSTEM_POWER_STATE_ON ++ off = sushy.SYSTEM_POWER_STATE_OFF ++ po = sushy.SYSTEM_POWER_STATE_POWERING_OFF ++ progression = [on, po, off, off, on] ++ sys_m = _system_mock_with_refresh_progression(progression) ++ mock_get_system.return_value = sys_m + + task.driver.power.reboot(task) + + # Asserts +- system_result[0].reset_system.assert_has_calls([ ++ sys_m.reset_system.assert_has_calls([ + mock.call(sushy.RESET_FORCE_OFF), + mock.call(sushy.RESET_ON), + ]) +- mock_get_system.assert_called_with(task.node) +- self.assertEqual(3, mock_get_system.call_count) ++ mock_get_system.assert_called_once_with(task.node) + mock_restore_bootdev.assert_called_once_with( +- task.driver.management, task, system_result[0]) ++ task.driver.management, task, sys_m) + +- @mock.patch.object(lc.BackOffLoopingCall, '_sleep', autospec=True) + @mock.patch('time.sleep', autospec=True) + @mock.patch.object(redfish_mgmt.RedfishManagement, 'restore_boot_device', + autospec=True) + @mock.patch.object(redfish_utils, 'get_system', autospec=True) + def test_reboot_from_power_on_with_disable_power_off( +- self, mock_get_system, mock_restore_bootdev, mock_sleep, +- mock_lc_sleep): ++ self, mock_get_system, mock_restore_bootdev, mock_sleep): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node.disable_power_off = True +- system_result = [ +- # Initial state +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_ON), +- # Transient state - powering off +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_OFF), +- # Final state - down powering off +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_ON) +- ] +- mock_get_system.side_effect = system_result ++ on = sushy.SYSTEM_POWER_STATE_ON ++ progression = [on, on, on, on, on] ++ sys_m = _system_mock_with_refresh_progression(progression) ++ mock_get_system.return_value = sys_m + + task.driver.power.reboot(task) + + # Asserts +- system_result[0].reset_system.assert_called_once_with( ++ sys_m.reset_system.assert_called_once_with( + sushy.RESET_FORCE_RESTART) +- mock_get_system.assert_called_with(task.node) +- self.assertEqual(3, mock_get_system.call_count) ++ mock_get_system.assert_called_once_with(task.node) + mock_restore_bootdev.assert_called_once_with( +- task.driver.management, task, system_result[0]) ++ task.driver.management, task, sys_m) + mock_sleep.assert_called_with(15) + +- @mock.patch.object(lc.BackOffLoopingCall, '_sleep', autospec=True) +- @mock.patch.object(time, 'sleep', autospec=True) + @mock.patch.object(redfish_utils, 'get_system', autospec=True) +- def test_reboot_not_reached(self, mock_get_system, mock_sleep, +- mock_lc_sleep): ++ def test_reboot_not_reached(self, mock_get_system): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: +- self.config(power_state_change_timeout=2, group='conductor') + fake_system = mock_get_system.return_value + fake_system.power_state = sushy.SYSTEM_POWER_STATE_OFF + +@@ -504,8 +483,7 @@ class RedfishPowerTestCase(db_base.DbTestCase): + + # Asserts + fake_system.reset_system.assert_called_once_with(sushy.RESET_ON) +- mock_get_system.assert_called_with(task.node) +- mock_sleep.assert_called_with(0) ++ mock_get_system.assert_called_once_with(task.node) + + @mock.patch.object(sushy, 'Sushy', autospec=True) + @mock.patch.object(redfish_utils, 'get_system', autospec=True) +@@ -529,16 +507,12 @@ class RedfishPowerTestCase(db_base.DbTestCase): + @mock.patch.object(redfish_utils, 'get_system', autospec=True) + def test_reboot_fail_on_power_on(self, mock_get_system, mock_sushy, + mock_lc_sleep): +- system_result = [ +- # Initial state +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_ON), +- # Transient state - powering off +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_OFF), +- # Final state - down powering off +- mock.Mock(power_state=sushy.SYSTEM_POWER_STATE_ON) +- ] +- mock_get_system.side_effect = system_result +- fake_system = system_result[0] ++ on = sushy.SYSTEM_POWER_STATE_ON ++ off = sushy.SYSTEM_POWER_STATE_OFF ++ po = sushy.SYSTEM_POWER_STATE_POWERING_OFF ++ progression = [on, po, off, off, on] ++ fake_system = _system_mock_with_refresh_progression(progression) ++ mock_get_system.return_value = fake_system + fake_system.reset_system.side_effect = [ + None, + sushy.exceptions.SushyError(), +@@ -553,7 +527,7 @@ class RedfishPowerTestCase(db_base.DbTestCase): + mock.call(sushy.RESET_FORCE_OFF), + mock.call(sushy.RESET_ON), + ]) +- mock_get_system.assert_called_with(task.node) ++ mock_get_system.assert_called_once_with(task.node) + + def test_get_supported_power_states(self): + with task_manager.acquire(self.context, self.node.uuid, +diff --git a/releasenotes/notes/redfish-powering-off-transitional-c2ffdd7eacff24b6.yaml b/releasenotes/notes/redfish-powering-off-transitional-c2ffdd7eacff24b6.yaml +new file mode 100644 +index 000000000..c646e9e34 +--- /dev/null ++++ b/releasenotes/notes/redfish-powering-off-transitional-c2ffdd7eacff24b6.yaml +@@ -0,0 +1,15 @@ ++--- ++fixes: ++ - | ++ The Redfish power driver no longer maps the BMC ``PoweringOff`` state to ++ Ironic ``power off``. Ironic now keeps waiting until the machine reports ++ ``Off``, avoiding an immediate power-on while the system is still shutting ++ down (which some BMCs accept but ignore). Transitional/unknown Redfish ++ power states are reported as ``None`` to the conductor; periodic power ++ sync skips a cycle instead of clearing the database power state. ++features: ++ - | ++ New ``[redfish]power_on_wait_phases`` option (default ``1``). When set ++ above ``1``, power-on waits split ``power_state_change_timeout`` into ++ segments and re-issue the power-on reset between segments if the node is ++ still off, as a workaround for BMCs that drop the first request. +-- +2.39.5 (Apple Git-154) + diff --git a/patches/ironic/0002-Harden-vmedia-injection-ejection.patch b/patches/ironic/0002-Harden-vmedia-injection-ejection.patch new file mode 100644 index 000000000..e8395590e --- /dev/null +++ b/patches/ironic/0002-Harden-vmedia-injection-ejection.patch @@ -0,0 +1,533 @@ +From 0d853de019e0ec2ed65a6cf76824f309ef9276a1 Mon Sep 17 00:00:00 2001 +From: Vasyl Saienko +Date: Sat, 4 Apr 2026 17:48:11 +0300 +Subject: [PATCH 2/2] Harden vmedia injection/ejection + +When working with vmedia in redfish make sure that iso +is actually mounted/umounted via redfish polling +bofore completing mount/umount call. +--- + ironic/drivers/modules/redfish/boot.py | 124 ++++++++++++- + .../unit/drivers/modules/redfish/test_boot.py | 163 ++++++++++++++++-- + .../drivers/modules/redfish/test_power.py | 2 +- + 3 files changed, 272 insertions(+), 17 deletions(-) + +diff --git a/ironic/drivers/modules/redfish/boot.py b/ironic/drivers/modules/redfish/boot.py +index 24467fd34..0153ec900 100644 +--- a/ironic/drivers/modules/redfish/boot.py ++++ b/ironic/drivers/modules/redfish/boot.py +@@ -13,6 +13,8 @@ + # License for the specific language governing permissions and limitations + # under the License. + ++import time ++ + from oslo_log import log + import sushy + import tenacity +@@ -92,6 +94,10 @@ COMMON_PROPERTIES.update(RESCUE_PROPERTIES) + + IMAGE_SUBDIR = 'redfish' + ++# Poll Redfish VirtualMedia after InsertMedia / EjectMedia (not configurable). ++_VIRTUAL_MEDIA_REDFISH_POLL_TIMEOUT = 120 ++_VIRTUAL_MEDIA_REDFISH_POLL_INTERVAL = 3 ++ + + def _parse_driver_info(node): + """Gets the driver specific Node deployment info. +@@ -183,6 +189,108 @@ def _test_retry(exception): + return False + + ++def _vmedia_image_matches_url(image, boot_url): ++ """True if Redfish Image matches the URL we requested for the mount.""" ++ if not image: ++ return False ++ if image == boot_url: ++ return True ++ return image.rstrip('/') == boot_url.rstrip('/') ++ ++ ++def _vmedia_resolve_for_poll(resource, v_media): ++ """Return the VirtualMedia member to poll (by slot identity if set).""" ++ slot_identity = getattr(v_media, 'identity', None) ++ if isinstance(slot_identity, (str, int)): ++ for candidate in resource.virtual_media.get_members(): ++ if getattr(candidate, 'identity', None) == slot_identity: ++ return candidate ++ return v_media ++ ++ ++def _wait_vmedia_insert_confirmed(task, resource, v_media, boot_url, ++ boot_device): ++ """Poll Redfish until the virtual media slot reports inserted (and Image). ++ ++ InsertMedia may return success before the VirtualMedia resource reflects ++ the new media. Optionally verify Image when the BMC provides it. ++ """ ++ timeout = _VIRTUAL_MEDIA_REDFISH_POLL_TIMEOUT ++ interval = _VIRTUAL_MEDIA_REDFISH_POLL_INTERVAL ++ deadline = time.monotonic() + timeout ++ ++ while time.monotonic() < deadline: ++ vm = _vmedia_resolve_for_poll(resource, v_media) ++ try: ++ vm.refresh(force=True) ++ except AttributeError: ++ pass ++ except Exception as exc: ++ LOG.debug('Virtual media refresh while confirming insert for ' ++ 'node %(node)s: %(exc)s', ++ {'node': task.node.uuid, 'exc': exc}) ++ ++ if vm.inserted: ++ image = getattr(vm, 'image', None) ++ if (isinstance(image, str) and image ++ and not _vmedia_image_matches_url(image, boot_url)): ++ raise exception.InvalidParameterValue( ++ _('Virtual media reports inserted image %(image)s but ' ++ 'expected %(expected)s for node %(node)s on ' ++ '%(device)s.') % { ++ 'image': image, ++ 'expected': boot_url, ++ 'node': task.node.uuid, ++ 'device': boot_device}) ++ if not image: ++ LOG.debug('Virtual media slot reports inserted=True but ' ++ 'Image is not yet populated for node %(node)s; ' ++ 'treating InsertMedia as confirmed.', ++ {'node': task.node.uuid}) ++ return ++ ++ time.sleep(interval) ++ ++ raise exception.InvalidParameterValue( ++ _('Timed out after %(timeout)s seconds waiting for virtual media ' ++ 'to report inserted=True for node %(node)s (device %(device)s).') % { ++ 'timeout': timeout, ++ 'node': task.node.uuid, ++ 'device': boot_device}) ++ ++ ++def _wait_vmedia_eject_confirmed(task, resource, v_media, boot_device): ++ """Poll Redfish until the virtual media slot reports media not inserted.""" ++ timeout = _VIRTUAL_MEDIA_REDFISH_POLL_TIMEOUT ++ interval = _VIRTUAL_MEDIA_REDFISH_POLL_INTERVAL ++ deadline = time.monotonic() + timeout ++ device_desc = boot_device if boot_device is not None else v_media.name ++ ++ while time.monotonic() < deadline: ++ vm = _vmedia_resolve_for_poll(resource, v_media) ++ try: ++ vm.refresh(force=True) ++ except AttributeError: ++ pass ++ except Exception as exc: ++ LOG.debug('Virtual media refresh while confirming eject for ' ++ 'node %(node)s: %(exc)s', ++ {'node': task.node.uuid, 'exc': exc}) ++ ++ if not vm.inserted: ++ return ++ ++ time.sleep(interval) ++ ++ raise exception.InvalidParameterValue( ++ _('Timed out after %(timeout)s seconds waiting for virtual media ' ++ 'to report inserted=False after eject for node %(node)s ' ++ '(device %(device)s).') % { ++ 'timeout': timeout, ++ 'node': task.node.uuid, ++ 'device': device_desc}) ++ ++ + def _has_vmedia_via_systems(system): + """Indicates if virtual media is available through Systems + +@@ -374,12 +482,16 @@ def _insert_vmedia_in_resource(task, resource, boot_url, boot_device, + continue + + if v_media.inserted: +- if v_media.image == boot_url: ++ image = getattr(v_media, 'image', None) ++ if isinstance(image, str) and _vmedia_image_matches_url( ++ image, boot_url): + LOG.debug("Boot media %(boot_url)s is already " + "inserted into %(boot_device)s for node " + "%(node)s", {'node': task.node.uuid, + 'boot_url': boot_url, + 'boot_device': boot_device}) ++ _wait_vmedia_insert_confirmed(task, resource, v_media, ++ boot_url, boot_device) + return True + + continue +@@ -440,6 +552,8 @@ def _insert_vmedia_in_resource(task, resource, boot_url, boot_device, + continue + raise + ++ _wait_vmedia_insert_confirmed(task, resource, v_media, boot_url, ++ boot_device) + LOG.info("Inserted boot media %(boot_url)s into " + "%(boot_device)s for node " + "%(node)s", {'node': task.node.uuid, +@@ -528,15 +642,17 @@ def _eject_vmedia_from_resource(task, resource, boot_device=None): + 'available_device': sushy.VIRTUAL_MEDIA_DVD}) + else: + continue +- inserted = v_media.inserted +- if inserted: ++ had_media = v_media.inserted ++ if had_media: + v_media.eject_media() ++ _wait_vmedia_eject_confirmed(task, resource, v_media, ++ boot_device) + found = True + + LOG.info("Boot media is%(already)s ejected from " + "%(boot_device)s for node %(node)s" + "", {'node': task.node.uuid, +- 'already': '' if inserted else ' already', ++ 'already': '' if had_media else ' already', + 'boot_device': v_media.name}) + return found + +diff --git a/ironic/tests/unit/drivers/modules/redfish/test_boot.py b/ironic/tests/unit/drivers/modules/redfish/test_boot.py +index 13907a25b..d49d6101a 100644 +--- a/ironic/tests/unit/drivers/modules/redfish/test_boot.py ++++ b/ironic/tests/unit/drivers/modules/redfish/test_boot.py +@@ -16,6 +16,7 @@ + from unittest import mock + + import sushy ++import time + + from ironic.common import boot_devices + from ironic.common import exception +@@ -35,6 +36,44 @@ from ironic.tests.unit.objects import utils as obj_utils + INFO_DICT = db_utils.get_test_redfish_info() + + ++def _stub_vmedia_insert_reports_mounted(mock_vm, image_url='img-url'): ++ """Configure insert_media to flip Redfish-like inserted/Image state.""" ++ ++ def _on_insert(*args, **kwargs): ++ mock_vm.inserted = True ++ mock_vm.image = image_url ++ ++ mock_vm.insert_media.side_effect = _on_insert ++ ++ ++def _stub_vmedia_eject_reports_unmounted(mock_vm): ++ """Configure eject_media so Redfish-like inserted becomes False.""" ++ ++ def _on_eject(*args, **kwargs): ++ mock_vm.inserted = False ++ mock_vm.image = None ++ ++ mock_vm.eject_media.side_effect = _on_eject ++ ++ ++def _patch_monotonic_for_vmedia_poll_timeout(values=(0, 0, 0, 6)): ++ """Patch ``time.monotonic`` for poll-timeout tests. ++ ++ Returns scripted values first, then the real clock so libraries such as ++ tenacity do not exhaust a fixed ``side_effect`` list. ++ """ ++ real_mono = time.monotonic ++ seq = iter(values) ++ ++ def _fake(): ++ try: ++ return next(seq) ++ except StopIteration: ++ return real_mono() ++ ++ return mock.patch('time.monotonic', side_effect=_fake, autospec=True) ++ ++ + class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + + def setUp(self): +@@ -1501,6 +1540,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + mock_manager.virtual_media.get_members.return_value = [ + mock_vmedia_cd, mock_vmedia_floppy] + ++ _stub_vmedia_insert_reports_mounted(mock_vmedia_cd) + redfish_boot._insert_vmedia( + task, [mock_manager], 'img-url', sushy.VIRTUAL_MEDIA_CD) + +@@ -1524,6 +1564,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + mock_manager.virtual_media.get_members.return_value = [ + mock_vmedia_dvd] + ++ _stub_vmedia_insert_reports_mounted(mock_vmedia_dvd) + redfish_boot._insert_vmedia( + task, [mock_manager], 'img-url', sushy.VIRTUAL_MEDIA_CD) + +@@ -1555,6 +1596,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + mock_manager.virtual_media.get_members.return_value = [ + mock_vmedia_dvd_1, mock_vmedia_dvd_2] + ++ _stub_vmedia_insert_reports_mounted(mock_vmedia_dvd_2) + redfish_boot._insert_vmedia( + task, [mock_manager], 'img-url', sushy.VIRTUAL_MEDIA_CD) + +@@ -1638,6 +1680,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + mock_manager.virtual_media.get_members.return_value = [ + mock_vmedia_no_action, mock_vmedia_with_action] + ++ _stub_vmedia_insert_reports_mounted(mock_vmedia_with_action) + redfish_boot._insert_vmedia( + task, [mock_manager], 'img-url', sushy.VIRTUAL_MEDIA_CD) + +@@ -1680,6 +1723,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + mock_manager.virtual_media.get_members.return_value = [ + mock_vmedia_405, mock_vmedia_success] + ++ _stub_vmedia_insert_reports_mounted(mock_vmedia_success) + redfish_boot._insert_vmedia( + task, [mock_manager], 'img-url', sushy.VIRTUAL_MEDIA_CD) + +@@ -1721,12 +1765,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + ) + mock_manager = mock.MagicMock() + +- mock_response = mock.MagicMock() +- mock_response.json.return_value = { +- "error": {"message": "Device is ejecting"} +- } +- +- mock_vmedia_cd.insert_media.side_effect = mock_response ++ _stub_vmedia_insert_reports_mounted(mock_vmedia_cd) + mock_manager.virtual_media.get_members.return_value = [ + mock_vmedia_cd] + +@@ -1735,6 +1774,59 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + + self.assertEqual(mock_vmedia_cd.insert_media.call_count, 1) + ++ @mock.patch('time.sleep', lambda *args, **kwargs: None) ++ @mock.patch.object(redfish_boot, '_has_vmedia_via_systems', autospec=True) ++ @mock.patch.object(redfish_utils, 'get_system', autospec=True) ++ def test__insert_vmedia_mount_confirm_timeout( ++ self, mock_sys, mock_vmd_sys): ++ mock_vmd_sys.return_value = False ++ with task_manager.acquire(self.context, self.node.uuid, ++ shared=True) as task: ++ mock_vmedia_cd = mock.MagicMock( ++ inserted=False, ++ media_types=[sushy.VIRTUAL_MEDIA_CD]) ++ mock_manager = mock.MagicMock() ++ mock_manager.virtual_media.get_members.return_value = [ ++ mock_vmedia_cd] ++ ++ with mock.patch.multiple( ++ redfish_boot, ++ _VIRTUAL_MEDIA_REDFISH_POLL_TIMEOUT=5, ++ _VIRTUAL_MEDIA_REDFISH_POLL_INTERVAL=1), \ ++ _patch_monotonic_for_vmedia_poll_timeout(): ++ self.assertRaises( ++ exception.InvalidParameterValue, ++ redfish_boot._insert_vmedia, ++ task, [mock_manager], 'img-url', sushy.VIRTUAL_MEDIA_CD) ++ ++ mock_vmedia_cd.insert_media.assert_called_once_with( ++ 'img-url', inserted=True, write_protected=True) ++ ++ @mock.patch('time.sleep', lambda *args, **kwargs: None) ++ @mock.patch.object(redfish_boot, '_has_vmedia_via_systems', autospec=True) ++ @mock.patch.object(redfish_utils, 'get_system', autospec=True) ++ def test__insert_vmedia_mount_wrong_image(self, mock_sys, mock_vmd_sys): ++ mock_vmd_sys.return_value = False ++ with task_manager.acquire(self.context, self.node.uuid, ++ shared=True) as task: ++ mock_vmedia_cd = mock.MagicMock( ++ inserted=False, ++ media_types=[sushy.VIRTUAL_MEDIA_CD]) ++ ++ def _wrong_image(*args, **kwargs): ++ mock_vmedia_cd.inserted = True ++ mock_vmedia_cd.image = 'http://other/wrong.iso' ++ ++ mock_vmedia_cd.insert_media.side_effect = _wrong_image ++ mock_manager = mock.MagicMock() ++ mock_manager.virtual_media.get_members.return_value = [ ++ mock_vmedia_cd] ++ ++ self.assertRaises( ++ exception.InvalidParameterValue, ++ redfish_boot._insert_vmedia, ++ task, [mock_manager], 'img-url', sushy.VIRTUAL_MEDIA_CD) ++ + @mock.patch('time.sleep', lambda *args, **kwargs: None) + @mock.patch.object(redfish_boot, '_has_vmedia_via_systems', autospec=True) + @mock.patch.object(redfish_utils, 'get_system', autospec=True) +@@ -1802,6 +1894,9 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + mock_redfish_utils.get_system.return_value.managers = [ + mock_manager] + ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_cd) ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_floppy) ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_dvd) + redfish_boot.eject_vmedia(task) + + mock_vmedia_cd.eject_media.assert_called_once_with() +@@ -1835,6 +1930,7 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + mock_redfish_utils.get_system.return_value.managers = [ + mock_manager] + ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_cd) + redfish_boot.eject_vmedia(task, sushy.VIRTUAL_MEDIA_CD) + + mock_vmedia_cd.eject_media.assert_called_once_with() +@@ -1872,6 +1968,8 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + mock_redfish_utils.get_system.return_value.managers = [ + mock_manager] + ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_dvd_1) ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_dvd_2) + redfish_boot.eject_vmedia(task, sushy.VIRTUAL_MEDIA_CD) + + mock_vmedia_dvd_1.eject_media.assert_called_once_with() +@@ -1882,6 +1980,38 @@ class RedfishVirtualMediaBootTestCase(db_base.DbTestCase): + mock_cleanup_iso.assert_called_once_with(task) + mock_cleanup_disk.assert_not_called() + ++ @mock.patch('time.sleep', lambda *args, **kwargs: None) ++ @mock.patch.object(image_utils, 'cleanup_disk_image', autospec=True) ++ @mock.patch.object(image_utils, 'cleanup_iso_image', autospec=True) ++ @mock.patch.object(redfish_boot, '_has_vmedia_via_systems', autospec=True) ++ @mock.patch.object(redfish_boot, 'redfish_utils', autospec=True) ++ def test_eject_vmedia_confirm_timeout( ++ self, mock_redfish_utils, mock_vmd_sys, mock_cleanup_iso, ++ mock_cleanup_disk): ++ mock_vmd_sys.return_value = False ++ with task_manager.acquire(self.context, self.node.uuid, ++ shared=True) as task: ++ mock_vmedia_cd = mock.MagicMock( ++ inserted=True, ++ media_types=[sushy.VIRTUAL_MEDIA_CD]) ++ mock_manager = mock.MagicMock() ++ mock_manager.virtual_media.get_members.return_value = [ ++ mock_vmedia_cd] ++ mock_redfish_utils.get_system.return_value.managers = [ ++ mock_manager] ++ ++ with mock.patch.multiple( ++ redfish_boot, ++ _VIRTUAL_MEDIA_REDFISH_POLL_TIMEOUT=5, ++ _VIRTUAL_MEDIA_REDFISH_POLL_INTERVAL=1), \ ++ _patch_monotonic_for_vmedia_poll_timeout(): ++ self.assertRaises( ++ exception.InvalidParameterValue, ++ redfish_boot.eject_vmedia, ++ task) ++ ++ mock_vmedia_cd.eject_media.assert_called_once_with() ++ + @mock.patch.object(redfish_boot, '_has_vmedia_via_systems', autospec=True) + @mock.patch.object(redfish_boot, 'redfish_utils', autospec=True) + def test_eject_vmedia_not_inserted(self, mock_redfish_utils, mock_vmd_sys): +@@ -2961,6 +3091,7 @@ class RedfishVirtualMediaBootViaSystemTestCase(db_base.DbTestCase): + mock_system.return_value.virtual_media.get_members.return_value = [ + mock_vmedia_cd, mock_vmedia_floppy] + ++ _stub_vmedia_insert_reports_mounted(mock_vmedia_cd) + redfish_boot._insert_vmedia( + task, [mock_manager], 'img-url', sushy.VIRTUAL_MEDIA_CD) + +@@ -2984,6 +3115,7 @@ class RedfishVirtualMediaBootViaSystemTestCase(db_base.DbTestCase): + mock_system.return_value.virtual_media.get_members.return_value = [ + mock_vmedia_dvd] + ++ _stub_vmedia_insert_reports_mounted(mock_vmedia_dvd) + redfish_boot._insert_vmedia( + task, [mock_manager], 'img-url', sushy.VIRTUAL_MEDIA_CD) + +@@ -3016,6 +3148,7 @@ class RedfishVirtualMediaBootViaSystemTestCase(db_base.DbTestCase): + mock_system.return_value.virtual_media.get_members.return_value = [ + mock_vmedia_dvd_1, mock_vmedia_dvd_2] + ++ _stub_vmedia_insert_reports_mounted(mock_vmedia_dvd_2) + redfish_boot._insert_vmedia( + task, [mock_manager], 'img-url', sushy.VIRTUAL_MEDIA_CD) + +@@ -3055,12 +3188,7 @@ class RedfishVirtualMediaBootViaSystemTestCase(db_base.DbTestCase): + ) + mock_manager = mock.MagicMock() + +- mock_response = mock.MagicMock() +- mock_response.json.return_value = { +- "error": {"message": "Device is ejecting"} +- } +- +- mock_vmedia_cd.insert_media.side_effect = mock_response ++ _stub_vmedia_insert_reports_mounted(mock_vmedia_cd) + mock_system.return_value.virtual_media.get_members.return_value = [ + mock_vmedia_cd] + +@@ -3119,6 +3247,9 @@ class RedfishVirtualMediaBootViaSystemTestCase(db_base.DbTestCase): + mock_system.virtual_media.get_members.return_value = [ + mock_vmedia_cd, mock_vmedia_floppy, mock_vmedia_dvd] + ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_cd) ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_floppy) ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_dvd) + redfish_boot.eject_vmedia(task) + + mock_vmedia_cd.eject_media.assert_called_once_with() +@@ -3163,6 +3294,9 @@ class RedfishVirtualMediaBootViaSystemTestCase(db_base.DbTestCase): + mock_manager.virtual_media.get_members.return_value = [ + mock_vmedia_cd] + ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_cd) ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_floppy) ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_dvd) + redfish_boot.eject_vmedia(task) + + mock_vmedia_cd.eject_media.assert_called_once_with() +@@ -3197,6 +3331,7 @@ class RedfishVirtualMediaBootViaSystemTestCase(db_base.DbTestCase): + mock_system.virtual_media.get_members.return_value = [ + mock_vmedia_cd, mock_vmedia_floppy] + ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_cd) + redfish_boot.eject_vmedia(task, sushy.VIRTUAL_MEDIA_CD) + + mock_vmedia_cd.eject_media.assert_called_once_with() +@@ -3235,6 +3370,8 @@ class RedfishVirtualMediaBootViaSystemTestCase(db_base.DbTestCase): + mock_system.virtual_media.get_members.return_value = [ + mock_vmedia_dvd_1, mock_vmedia_dvd_2] + ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_dvd_1) ++ _stub_vmedia_eject_reports_unmounted(mock_vmedia_dvd_2) + redfish_boot.eject_vmedia(task, sushy.VIRTUAL_MEDIA_CD) + + mock_vmedia_dvd_1.eject_media.assert_called_once_with() +@@ -3413,6 +3550,8 @@ class RedfishVirtualMediaBootViaSystemTestCase(db_base.DbTestCase): + mock_vmedia_1.identity = "1" + mock_vmedia_1.media_types = [sushy.VIRTUAL_MEDIA_CD] + mock_vmedia_1.inserted = False ++ _stub_vmedia_insert_reports_mounted( ++ mock_vmedia_1, 'http://example.com/boot.iso') + + # Mock resource with virtual media collection + mock_resource = mock.MagicMock() +diff --git a/ironic/tests/unit/drivers/modules/redfish/test_power.py b/ironic/tests/unit/drivers/modules/redfish/test_power.py +index 625df9b93..44ee28d59 100644 +--- a/ironic/tests/unit/drivers/modules/redfish/test_power.py ++++ b/ironic/tests/unit/drivers/modules/redfish/test_power.py +@@ -15,8 +15,8 @@ + + from unittest import mock + +-import sushy + from oslo_service import loopingcall as lc ++import sushy + + from ironic.common import exception + from ironic.common import states +-- +2.39.5 (Apple Git-154) + diff --git a/prepare-image.sh b/prepare-image.sh index 487e3638e..579d56599 100755 --- a/prepare-image.sh +++ b/prepare-image.sh @@ -47,6 +47,11 @@ IRONIC_PKG_LIST_FINAL="/tmp/ironic-packages-list-final" python3.12 -c 'import os; import sys; import jinja2; sys.stdout.write(jinja2.Template(sys.stdin.read()).render(env=os.environ, path=os.path))' < "${IRONIC_PKG_LIST}" > "${IRONIC_PKG_LIST_FINAL}" +# Replace git requirements with patched /sources checkouts when patches/ exists. +if [[ -d /tmp/patches ]]; then + /bin/patch-image.sh +fi + if [[ -n ${SUSHY_SOURCE:-} ]]; then sed -i '/^sushy===/d' "${UPPER_CONSTRAINTS_PATH}" fi @@ -83,12 +88,6 @@ rm -f /usr/share/ironic/ironic-dist.conf # add ironic to apache group usermod -aG ironic apache -# apply patches if present # -if [[ -n "${PATCH_LIST:-}" ]]; then - if [[ -s "/tmp/${PATCH_LIST}" ]]; then - /bin/patch-image.sh - fi -fi rm -f /bin/patch-image.sh dnf clean all diff --git a/upper-constraints.txt b/upper-constraints.txt index 8df6a8c5b..a6fd7b619 100644 --- a/upper-constraints.txt +++ b/upper-constraints.txt @@ -5,28 +5,27 @@ # testing, and can contain known vulnerabilities. Consumers are # *STRONGLY* encouraged to rely on curated distributions of OpenStack # or manage security patching of dependencies themselves. -voluptuous===0.15.2 +voluptuous===0.16.0 chardet===5.2.0 enum-compat===0.0.3 rsa===4.9.1 -restructuredtext-lint===1.4.0 netmiko===4.6.0 sshtunnel===0.4.0 PasteDeploy===3.1.0 -typing===3.7.4.3 Routes===2.5.1 rtslib-fb===2.2.3 -oslo.limit===2.9.1 -tzdata===2025.2 +oslo.limit===2.10.0 +restructuredtext_lint===2.0.2 +tzdata===2025.3 smmap===5.0.2 confget===5.1.2 -XStatic-Angular-Bootstrap===2.5.0.0 +XStatic-Angular-Bootstrap===2.5.0.1 WebOb===1.8.9 sphinxcontrib-actdiag===3.0.0 pecan===1.7.0 os-api-ref===3.1.0 python-ldap===3.4.5 -oslo.concurrency===7.2.0 +oslo.concurrency===7.4.0 websocket-client===1.9.0 osprofiler===4.3.0 os-resource-classes===1.1.0 @@ -37,12 +36,12 @@ lxml===6.0.2 vintage===0.4.1 rst2txt===1.1.0 setproctitle===1.3.7 -pytest===8.4.2 +pytest===9.0.2 python-slugify===8.0.4 cursive===0.2.3 -oslo.service===4.4.1 -django-appconf===1.1.0 -ntc_templates===8.1.0 +oslo.service===4.5.1 +django-appconf===1.2.0 +ntc_templates===9.0.0 sphinxcontrib-nwdiag===2.0.0 rbd-iscsi-client===0.1.8 alabaster===1.0.0 @@ -50,117 +49,111 @@ pbr===7.0.3 munch===4.0.0 waiting===1.5.0 attrs===25.4.0 -microversion-parse===2.0.0 jwcrypto===1.5.6 Pint===0.24.4 -oslo.i18n===6.7.1 +oslo.i18n===6.7.2 jsonpath-rw-ext===1.2.2 -python-mistralclient===6.1.0 -oslo.context===6.2.0 -rcssmin===1.1.2 +python-mistralclient===6.2.0 +oslo.context===6.3.0 +rcssmin===1.2.2 pycadf===4.0.1 -grpcio===1.76.0 -sniffio===1.3.1 -fixtures===4.2.6 -neutron-lib===3.22.0 -XStatic-FileSaver===1.3.2.0 -jaraco.functools===4.3.0 -oslo.metrics===0.14.0 +grpcio===1.78.1 +fixtures===4.3.1 +neutron-lib===3.24.0 +XStatic-FileSaver===1.3.2.1 +jaraco.functools===4.4.0 +oslo.metrics===0.15.1 storage-interfaces===1.0.5 -pydantic===2.12.3 -persist-queue===1.0.0 +pydantic===2.12.5 +persist-queue===1.1.0 pystache===0.6.8 -XStatic-Font-Awesome===4.7.0.0 +XStatic-Font-Awesome===6.2.1.2 nose===1.3.7 waitress===3.0.2 os-refresh-config===14.0.1 -pysnmp===7.1.21 +pysnmp===7.1.22 Mako===1.3.10 sphinxcontrib-htmlhelp===2.1.0 -XStatic-jQuery===3.5.1.1 +XStatic-jQuery===3.7.1.1 ddt===1.7.2 -XStatic-Graphlib===2.1.7.0 pyserial===3.5 -moto===5.1.15 +moto===5.1.21 infi.dtypes.wwn===0.1.1 -python-freezerclient===6.1.0 -python-vitrageclient===5.3.0 -py-pure-client===1.77.0 +pcre2===0.6.0 +python-freezerclient===6.2.0 +python-vitrageclient===5.4.0 +py-pure-client===1.83.1 krest===1.3.8 psycopg2===2.9.11 networkx===3.4.2 -cheroot===11.0.0 -XStatic-Angular===1.8.2.2 -types-requests===2.31.0.6 +cheroot===11.1.2 +XStatic-Angular===1.8.2.3 zuul-sphinx===0.7.0 ply===3.11 -google-api-core===2.27.0 +google-api-core===2.30.0 requests-toolbelt===1.0.0 simplejson===3.20.2 -types-paramiko===4.0.0.20250822 -python-swiftclient===4.9.0 +python-swiftclient===4.10.0 pyOpenSSL===24.2.1 typing-inspection===0.4.2 monasca-common===3.8.0 hyperframe===6.1.0 zeroconf===0.148.0 scipy===1.15.3 -opentelemetry-exporter-otlp===1.38.0 -python-gnupg===0.5.5 +opentelemetry-exporter-otlp===1.39.1 rsd-lib===1.2.0 -XStatic-Jasmine===2.4.1.2 -googleapis-common-protos===1.71.0 -python-glanceclient===4.10.0 -prometheus_client===0.23.1 +XStatic-Jasmine===2.4.1.3 +googleapis-common-protos===1.72.0 +python-glanceclient===4.11.0 +prometheus_client===0.24.1 jaraco.classes===3.4.0 debtcollector===3.0.0 -responses===0.25.8 +responses===0.26.0 +backports.strenum===1.3.1;python_version=='3.10' croniter===6.0.0 -horizon===25.5.1 -octavia-lib===3.10.0 -python-watcherclient===4.9.0 +horizon===25.7.0 +octavia-lib===3.11.0 +python-watcherclient===4.10.0 MarkupSafe===3.0.3 -types-python-dateutil===2.9.0.20251008 -ruamel.yaml.clib===0.2.14 doc8===2.0.0 -pymongo===4.15.3 -python-cloudkittyclient===5.4.0 -soupsieve===2.8 -sqlparse===0.5.3 +pymongo===4.16.0 +python-cloudkittyclient===6.1.0 +soupsieve===2.8.3 +sqlparse===0.5.5 oslotest===6.0.0 jsonpointer===3.0.0 defusedxml===0.7.1 -opentelemetry-sdk===1.38.0 +opentelemetry-sdk===1.39.1 netaddr===1.3.0 -pyghmi===1.6.6 +pyghmi===1.6.13 sphinxcontrib-blockdiag===3.0.0 -aiosqlite===0.21.0 +aiosqlite===0.22.1 gnocchiclient===7.2.0 -wcwidth===0.2.14 +wcwidth===0.6.0 sphinxcontrib.datatemplates===0.11.0 jsonpath-rw===1.4.0 -prettytable===3.16.0 +prettytable===3.17.0 vine===5.1.0 -pathspec===0.12.1 -taskflow===6.0.2 +pathspec===1.0.4 +taskflow===6.2.0 arrow===1.4.0 semantic-version===2.10.0 +ConfigArgParse===1.7.1 async-timeout===5.0.1 -virtualbmc===3.2.0 -SQLAlchemy===2.0.44 +virtualbmc===3.3.0 +SQLAlchemy===2.0.46 pyroute2===0.8.1 -google-auth===2.41.1 +google-auth===2.48.0 kazoo===2.10.0 pyspnego===0.12.0 -XStatic-roboto-fontface===0.5.0.0 +XStatic-roboto-fontface===0.8.0.1 pyudev===0.24.4 -eventlet===0.40.3 +eventlet===0.40.4 openstack-doc-tools===4.0.1 -oslo.messaging===17.1.0 +oslo.messaging===17.3.0 jira===3.10.5 -PyJWT===2.10.1 +PyJWT===2.11.0 typing_extensions===4.15.0 -XStatic-lodash===4.16.4.2 zVMCloudConnector===1.6.3 paramiko===4.0.0 ifaddr===0.2.0 @@ -168,407 +161,401 @@ reno===4.1.0 ncclient===0.7.0 imagesize===1.4.1 pydot===4.0.1 -urllib3===1.26.20 +urllib3===2.6.3 graphviz===0.21 PyKMIP===0.10.0 -python-observabilityclient===1.2.0 -whereto===0.4.0 +python-observabilityclient===1.3.0 +whereto===0.5.0 +networking-generic-switch===9.1.0 pywbem===1.7.3 -python-subunit===1.4.4 -pycparser===2.23 +python-subunit===1.4.5 +pycparser===3.0 mock===5.2.0 PyYAML===6.0.3 -beautifulsoup4===4.14.2 -ovs===3.6.0 +beautifulsoup4===4.14.3 +ovs===3.7.0 cryptography===43.0.3 httpcore===1.0.9 URLObject===3.0.0 psycopg2-binary===2.9.11 -glance_store===5.2.0 -openstack-release-test===8.1.0 +glance_store===5.4.0 +openstack-release-test===8.3.0 requests-mock===1.12.1 os-apply-config===14.0.1 -gunicorn===23.0.0 +gunicorn===25.1.0 storpool===7.3.0 textfsm===2.1.0 python-3parclient===4.2.14 -django-compressor===4.5.1 -libvirt-python===11.8.0 -python-zunclient===5.3.0 +libvirt-python===12.0.0 +python-zunclient===5.4.0 tzlocal===5.3.1 +sysv_ipc===1.2.0 sphinxcontrib-jsmath===1.0.1 -python-novaclient===18.11.0 +django_compressor===4.6.0 +awscurl===0.36 +python-novaclient===18.12.0 pact===1.12.0 -bcrypt===4.3.0 -exceptiongroup===1.3.0 +bcrypt===5.0.0 +exceptiongroup===1.3.1 os-client-config===2.3.0 -XStatic-Angular-Gettext===2.4.1.0 +XStatic-Angular-Gettext===2.4.1.1 h11===0.16.0 Pygments===2.19.2 -XStatic-Hogan===2.0.0.3 -XStatic-objectpath===1.2.1.0 -python-manilaclient===5.7.0 +XStatic-Hogan===2.0.0.5 +XStatic-objectpath===1.2.1.1 +python-manilaclient===6.0.0 sphinxcontrib-serializinghtml===2.0.0 requests===2.32.5 snowballstemmer===3.0.1 Jinja2===3.1.6 -XStatic-Bootstrap-SCSS===3.4.1.0 +XStatic-Bootstrap-SCSS===3.4.1.1 pyzabbix===1.3.1 ptyprocess===0.7.0 amqp===5.3.1 -ruamel.yaml===0.18.16 +ruamel.yaml===0.19.1 websockify===0.13.0 -gssapi===1.10.1 -XStatic-JQuery.quicksearch===2.0.3.2 +gssapi===1.11.1 +XStatic-JQuery.quicksearch===2.0.3.3 pyasn1_modules===0.4.1 mpmath===1.3.0 python-binary-memcached===0.31.4 -jaraco.context===6.0.1 +jaraco.context===6.1.0 django-debreach===2.1.0 sphinx-feature-classification===2.0.0 -XStatic-JQuery-Migrate===3.3.2.1 -pytest-html===4.1.1 +XStatic-JQuery-Migrate===3.3.2.2 +pytest-html===4.2.0 appdirs===1.4.4 -google-auth-httplib2===0.2.0 +google-auth-httplib2===0.3.0 daiquiri===3.4.0 influxdb===5.3.2 funcparserlib===2.0.0a0 passlib===1.7.4 -cliff===4.12.0 -os-brick===6.13.0 +cliff===4.13.2 +os-brick===7.0.0 valkey===6.1.1 scp===0.15.0 -lark===1.3.0 -python-zaqarclient===4.2.0 +lark===1.3.1 +python-zaqarclient===4.4.0 ldappool===3.0.0 hpack===4.1.0 -joblib===1.5.2 -google-api-python-client===2.185.0 -castellan===5.5.0 -oslo.versionedobjects===3.8.0 +joblib===1.5.3 +google-api-python-client===2.190.0 +castellan===5.6.0 +oslo.versionedobjects===3.9.0 enmerkar===0.7.1 -webcolors===24.11.1 -aodhclient===3.9.1 +webcolors===25.10.0 +aodhclient===3.10.1 autobahn===24.4.2 -SQLAlchemy-Utils===0.42.0 +SQLAlchemy-Utils===0.42.1 retryz===0.1.9 pluggy===1.6.0 -coverage===7.11.0 +coverage===7.13.4 freezegun===1.5.5 mdurl===0.1.2 toml===0.10.2 pycdlib===1.14.0 pyperclip===1.11.0 cassandra-driver===3.29.3 -XStatic-Angular-Schema-Form===0.8.13.0 -opentelemetry-exporter-otlp-proto-http===1.38.0 -gabbi===3.1.0 +XStatic-Angular-Schema-Form===0.8.13.1 +opentelemetry-exporter-otlp-proto-http===1.39.1 +gabbi===4.2.0 nwdiag===3.0.0 -XStatic-bootswatch===3.3.7.0 +XStatic-bootswatch===3.3.7.1 annotated-types===0.7.0 pytest-xdist===3.8.0 -XStatic-JS-Yaml===3.8.1.0 -XStatic-term.js===0.0.7.0 -oslo.log===7.2.1 -nodeenv===1.9.1 +XStatic-JS-Yaml===3.13.1.2 +XStatic-term.js===0.0.7.1 +oslo.log===8.1.0 +nodeenv===1.10.0 gossip===2.5.0 suds-community===1.2.0 -os_vif===4.2.1 +os_vif===4.3.0 qrcode===8.2 -oslo.middleware===7.0.0 -XStatic-mdi===1.6.50.2 -pydantic_core===2.41.4 +oslo.middleware===8.0.0 +XStatic-mdi===1.6.50.3 +pydantic_core===2.41.5 uritemplate===4.2.0 docutils===0.21.2 threadpoolctl===3.6.0 -os-ken===4.0.0 +os-ken===4.1.1 ujson===5.11.0 -selenium===3.141.0 +selenium===4.41.0 pytest-subtests===0.15.0 -mistral-lib===3.5.0 +mistral-lib===3.5.1 dogtag-pki===11.2.1 -XStatic-Angular-UUID===0.0.4.0 +XStatic-Angular-UUID===0.0.4.1 sphinxcontrib-seqdiag===3.0.0 os-win===5.9.0 capacity===1.3.14 markdown-it-py===4.0.0 retrying===1.4.2 -XStatic-Dagre===0.6.4.1 -platformdirs===4.5.0 +platformdirs===4.9.2 pydotplus===2.0.2 boto3===1.35.99 jeepney===0.9.0 -stestr===4.2.0 -pillow===12.0.0 -infoblox-client===0.6.1 +stestr===4.2.1 +pillow===12.1.1 +infoblox-client===0.6.2 pysmi-lextudio===1.4.3 -oslo.serialization===5.8.0 +oslo.serialization===5.9.1 warlock===2.1.0 -exabgp===4.2.25 +exabgp===5.0.1 aiomysql===0.3.2 -types-simplejson===3.20.0.20250822 -sphinxcontrib-httpdomain===1.8.1 +sphinxcontrib-httpdomain===2.0.0 metalsmith===2.5.0 -s3transfer===0.10.0 +s3transfer===0.10.4 text-unidecode===1.3 -sphinxcontrib-svg2pdfconverter===1.3.0 -oslo.vmware===4.7.0 -XStatic-moment===2.8.4.3 -autopage===0.5.2 +sphinxcontrib-svg2pdfconverter===2.0.0 +oslo.vmware===4.9.0 +autopage===0.6.0 gitdb===4.0.12 python-monascaclient===2.8.0 -opentelemetry-api===1.38.0 -automaton===3.2.0 -types-urllib3===1.26.25.14 +opentelemetry-api===1.39.1 +automaton===3.3.0 os-service-types===1.8.2 -keyring===25.6.0 +keyring===25.7.0 elementpath===4.8.0 wsgi_intercept===1.13.1 jsonschema-specifications===2025.9.1 testscenarios===0.5.0 sphinxcontrib-pecanwsme===0.11.0 sadisplay===0.4.9 -infinisdk===258.0.2 -rich-argparse===1.7.1 -packaging===25.0 -opentelemetry-exporter-otlp-proto-grpc===1.38.0 -XStatic-Dagre-D3===0.4.17.0 -psutil===7.1.1 +infinisdk===289.1.0 +rich-argparse===1.7.2 +packaging===26.0 +opentelemetry-exporter-otlp-proto-grpc===1.39.1 +psutil===7.2.2 txaio===25.9.2 -elasticsearch===2.4.1 +elasticsearch===9.3.0 django-nose===1.4.7 -asgiref===3.10.0 -XStatic-JQuery.TableSorter===2.14.5.2 +asgiref===3.11.1 +XStatic-JQuery.TableSorter===2.14.5.3 pifpaf===3.4.0 blockdiag===3.0.0 -testtools===2.7.2 +testtools===2.8.5 infi.dtypes.iqn===0.4.0 -XStatic-tv4===1.2.7.0 -XStatic-JSEncrypt===2.3.1.1 -python-cinderclient===9.8.0 -keystonemiddleware===10.12.0 +jsonpath-ng===1.7.0 +XStatic-tv4===1.2.7.1 +XStatic-JSEncrypt===2.3.1.2 +python-cinderclient===9.9.0 +keystonemiddleware===12.0.0 django-formtools===2.5.1 XStatic-Spin===1.2.5.3 -rich===14.2.0 -os-traits===3.5.0 +rich===14.3.3 +os-traits===3.6.0 typepy===1.3.4 -SecretStorage===3.4.0 -XStatic-Rickshaw===1.5.1.0 +SecretStorage===3.5.0 +XStatic-Rickshaw===1.5.1.3 iso8601===2.1.0 -tooz===7.0.0 +tooz===8.1.0 idna===3.11 -yamlloader===1.5.2 -protobuf===6.33.0 -sushy===5.9.0 -python-neutronclient===11.6.0 -types-setuptools===80.9.0.20250822 +yamlloader===1.6.0 +protobuf===6.33.5 +sushy===5.10.0 +python-neutronclient===11.8.0 pika===1.3.2 -oslo.cache===4.0.0 +oslo.cache===4.1.1 WebTest===3.0.7 os-collect-config===14.0.1 -edgegrid-python===2.0.2 -python-octaviaclient===3.12.0 +edgegrid-python===2.0.5 +python-octaviaclient===3.13.0 pysaml2===7.5.4 requests-oauthlib===2.0.0 -oslo.reports===3.6.0 +oslo.reports===3.7.0 pysnmp-lextudio===6.1.2 bitmath===1.3.3.1 -ceilometermiddleware===3.9.0 -testrepository===0.0.21 +ceilometermiddleware===3.10.0 +testrepository===0.0.22 +librt===0.8.1 sympy===1.14.0 -Logbook===1.8.2 -PyNaCl===1.6.0 -osc-lib===4.2.0 +Logbook===1.9.2 +PyNaCl===1.6.2 +osc-lib===4.4.0 python-consul===1.1.0 more-itertools===10.8.0 seqdiag===3.0.0 numpy===2.2.6 msgpack===1.1.2 Sphinx===8.1.3 -oslo.config===10.1.0 +oslo.config===10.3.0 openstackdocstheme===3.5.0 -osc-placement===4.7.0 -rpds-py===0.28.0 +osc-placement===4.8.0 +rpds-py===0.30.0 zake===0.2.2 flux===1.4.0 pysnmpcrypto===0.0.4 flexparser===0.4 -krb5===0.8.0 +krb5===0.9.0 PyMySQL===1.1.2 uhashring===2.4 -kubernetes===34.1.0 -httplib2===0.31.0 +kubernetes===35.0.0 +httplib2===0.31.2 betamax===0.9.0 construct===2.10.70 pytest-metadata===3.1.1 -pyparsing===3.2.5 +pyparsing===3.3.2 geomet===1.1.0 -opentelemetry-exporter-otlp-proto-common===1.38.0 +opentelemetry-exporter-otlp-proto-common===1.39.1 distlib===0.4.0 -XStatic-Moment-Timezone===0.5.22.0 dogpile.cache===1.5.0 -python-barbicanclient===7.2.0 -salt===3007.8 -opentelemetry-semantic-conventions===0.59b0 +python-barbicanclient===7.3.0 +salt===3007.13 +opentelemetry-semantic-conventions===0.60b1 api-object-schema===2.0.0 blinker===1.9.0 -aenum===3.1.16 WSME===0.12.1 -tomli===2.3.0 -oslo.upgradecheck===2.6.0 +tomli===2.4.0 +oslo.upgradecheck===2.7.1 sherlock===0.4.1 -stevedore===5.6.0 +stevedore===5.7.0 botocore===1.35.99 -xmltodict===1.0.2 +xmltodict===1.0.3 pyasn1===0.6.0 -oslo.rootwrap===7.8.0 -Django===4.2.25 +oslo.rootwrap===7.9.0 +Django===4.2.28 pexpect===4.9.0 -cmd2===2.7.0 +cmd2===3.2.0 python-json-logger===4.0.0 -redis===7.0.0 -jmespath===1.0.1 -click===8.3.0 -XStatic-smart-table===1.4.13.2 -kuryr-lib===3.3.1 -scrypt===0.9.4 +redis===7.2.0 +jmespath===1.1.0 +click===8.3.1 +XStatic-smart-table===1.4.13.3 +kuryr-lib===3.4.1 jsonpatch===1.33 libsass===0.23.0 os-testr===3.0.0 -cotyledon===2.1.0 +cotyledon===2.2.0 xattr===1.3.0 systemd-python===235 python-memcached===1.62 -openstacksdk===4.8.0 +openstacksdk===4.10.0 infi.dtypes.nqn===0.1.0 six===1.17.0 h2===4.3.0 dulwich===0.24.1 dfs-sdk===1.2.27 sentinels===1.1.1 -kombu===5.5.4 +kombu===5.6.2 distro===1.9.0 -zstd===1.5.7.2 +zstd===1.5.7.3 yaql===3.2.0 durationpy===0.10 requestsexceptions===1.4.0 testresources===2.0.2 -falcon===4.1.0 -tomlkit===0.13.3 -etcd3gw===2.4.2 +falcon===4.2.0 +tomlkit===0.14.0 +etcd3gw===2.5.0 Flask-RESTful===0.3.10 -GitPython===3.1.45 -python-ironicclient===5.14.0 -babel===2.17.0 +GitPython===3.1.46 +python-ironicclient===6.0.0 +babel===2.18.0 XStatic===1.0.3 -XStatic-Angular-FileUpload===12.2.13.0 -python-openstackclient===8.2.0 +XStatic-Angular-FileUpload===12.2.13.2 +python-openstackclient===9.0.0 pyzmq===27.1.0 -oslo.db===17.4.0 +oslo.db===18.0.0 simplegeneric===0.8.1 -python-pcre===0.7 yappi===1.7.3 mbstrdecoder===1.1.4 pymemcache===4.0.0 -wrapt===2.0.0 -oslo.privsep===3.8.0 +wrapt===2.1.1 +oslo.privsep===3.10.1 sphinxcontrib-apidoc===0.6.0 -oslo.policy===4.7.0 -hvac===2.3.0 +oslo.policy===5.0.0 +hvac===2.4.0 pyeclib===1.7.0 repoze.lru===0.7 rfc3986===2.0.0 -tenacity===9.1.2 +tenacity===9.1.4 invoke===2.2.1 -python-designateclient===6.3.0 +python-designateclient===6.4.0 pytest-cov===4.1.0 -reactivex===4.0.4 +reactivex===4.1.0 Paste===3.10.1 -pytest-django===4.11.1 -XStatic-Json2yaml===0.1.1.0 +pytest-django===4.12.0 +XStatic-Json2yaml===0.1.1.1 boto===2.49.0 hyperlink===21.0.0 mitba===1.1.1 -python-masakariclient===8.7.0 -Werkzeug===3.1.3 -APScheduler===3.11.0 +python-masakariclient===8.8.0 +Werkzeug===3.1.6 +APScheduler===3.11.2 xmlschema===2.5.1 -python-troveclient===8.9.0 +python-troveclient===8.10.0 cachez===0.1.2 -XStatic-Bootstrap-Datepicker===1.4.0.0 +XStatic-Bootstrap-Datepicker===1.4.0.1 netifaces===0.11.0 -cachetools===6.2.1 +cachetools===7.0.1 flexcache===0.3 sphinxcontrib-qthelp===2.0.0 -keystoneauth1===5.12.0 +keystoneauth1===5.13.1 statsd===4.0.1 -proto-plus===1.26.1 -python-keystoneclient===5.7.0 -diskimage-builder===3.39.0 -heat-translator===3.3.0 -python-magnumclient===4.9.0 +proto-plus===1.27.1 +python-keystoneclient===5.8.0 +diskimage-builder===3.40.2 +heat-translator===3.4.0 +python-magnumclient===4.10.0 docker===7.1.0 storops===1.2.11 -anyio===4.11.0 -XStatic-Angular-lrdragndrop===1.0.2.6 -ovsdbapp===2.14.1 +anyio===4.12.1 +XStatic-Angular-lrdragndrop===1.0.2.7 +ovsdbapp===2.16.0 aniso8601===10.0.1 -rjsmin===1.2.2 -icalendar===6.3.1 +rjsmin===1.2.5 +icalendar===7.0.1 +configparser===7.2.0 decorator===5.2.1 -DateTimeRange===2.3.1 +DateTimeRange===2.3.2 cffi===2.0.0 -python-cyborgclient===2.6.0 +python-cyborgclient===2.7.0 futurist===3.2.1 -jsonschema===4.25.1 +jsonschema===4.26.0 sphinxcontrib-devhelp===2.0.0 -python-blazarclient===4.4.0 -alembic===1.17.0 -execnet===2.1.1 -sphinxcontrib-programoutput===0.18 +python-blazarclient===4.5.0 +alembic===1.18.4 +execnet===2.1.2 +sphinxcontrib-programoutput===0.19 storpool.spopenstack===3.2.0 dnspython===2.8.0 oauthlib===3.3.1 zipp===3.23.0 -greenlet===3.2.4 -XStatic-Angular-Vis===4.16.0.0 +greenlet===3.3.2 +XStatic-Angular-Vis===4.16.0.1 iniconfig===2.3.0 referencing===0.37.0 -confluent-kafka===2.12.1 +confluent-kafka===2.13.0 backports.tarfile===1.2.0 -xvfbwrapper===0.2.15 -influxdb-client===1.49.0 -tosca-parser===2.13.0 +xvfbwrapper===0.2.20 +influxdb-client===1.50.0 +tosca-parser===2.14.0 python-consul2===0.1.5 charset-normalizer===3.4.4 -Flask===3.1.2 +Flask===3.1.3 httpx===0.28.1 sqlalchemy-filters===0.13.0 sphinxcontrib-runcmd===0.2.0 confspirator===0.3.0 fasteners===0.20 -importlib_metadata===8.7.0 +importlib_metadata===8.7.1 sortedcontainers===2.4.0 -python-linstor===1.25.3 -filelock===3.20.0 -python-tackerclient===2.4.0 -python-heatclient===4.3.0 -oslo.utils===9.2.0 +microversion_parse===2.1.0 +python-linstor===1.27.1 +filelock===3.24.3 +python-tackerclient===2.5.0 +python-heatclient===5.1.0 +oslo.utils===10.0.0 requests-kerberos===0.15.0 itsdangerous===2.2.0 -XStatic-jquery-ui===1.13.0.1 +XStatic-jquery-ui===1.13.0.2 monasca-statsd===2.7.0 python-dateutil===2.9.0.post0 -virtualenv===20.35.3 +virtualenv===20.39.0 colorama===0.4.6 confetti===2.5.3 ironic-lib===7.0.0 pytz===2025.2 -opentelemetry-proto===1.38.0 -XStatic-D3===3.5.17.0 +opentelemetry-proto===1.39.1 +XStatic-D3===3.5.17.1 actdiag===3.0.0 -sysv-ipc===1.1.0 sphinxcontrib-applehelp===2.0.0 scikit-learn===1.7.2 -networking-generic-switch===8.0.0 -awscurl===0.36