forked from openstack-snaps/snap-openstack-hypervisor
-
Notifications
You must be signed in to change notification settings - Fork 10
Adds functionality to validate externally managed instances storage mount before starting nova-compute #184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Raven-182
wants to merge
1
commit into
canonical:main
Choose a base branch
from
Raven-182:externally-managed-storage
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+272
−0
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| # SPDX-FileCopyrightText: 2026 - Canonical Ltd | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import logging | ||
| import subprocess | ||
| import tempfile | ||
| from pathlib import Path | ||
|
|
||
| LOG = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def path_declared_in_fstab( | ||
| path: Path, | ||
| fstab_path: Path = Path("/etc/fstab"), | ||
| ) -> bool: | ||
| """Return True if /etc/fstab has an entry targeting the given path.""" | ||
| try: | ||
| text = fstab_path.read_text() | ||
| except OSError as exc: | ||
| LOG.warning("Could not read %s (%s), skipping mount validation.", fstab_path, exc) | ||
| return False | ||
|
|
||
| for line in text.splitlines(): | ||
| line = line.strip() | ||
| if not line or line.startswith("#"): | ||
| continue | ||
| fields = line.split() | ||
| if len(fields) >= 2 and Path(fields[1]) == path: | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| def is_mounted(path: Path) -> bool: | ||
| """Return True if path is an active mount point.""" | ||
| try: | ||
| result = subprocess.run( | ||
| ["findmnt", "--mountpoint", str(path)], | ||
| capture_output=True, | ||
| check=False, | ||
| ) | ||
| except OSError as exc: | ||
| LOG.warning("Unable to execute findmnt (%s).", exc) | ||
| return False | ||
| return result.returncode == 0 | ||
|
|
||
|
|
||
| def is_usable(path: Path) -> bool: | ||
| """Return True if path is a writable directory.""" | ||
| if not path.is_dir(): | ||
| return False | ||
| try: | ||
| with tempfile.TemporaryFile(dir=path): | ||
| return True | ||
| except OSError: | ||
| return False | ||
|
|
||
|
|
||
| def validate_instances_mount( | ||
| instances_path: Path, | ||
| fstab_path: Path = Path("/etc/fstab"), | ||
| ) -> bool: | ||
| """Validate externally managed storage mount for Nova's instance path.""" | ||
| if not path_declared_in_fstab(instances_path, fstab_path): | ||
| LOG.debug("No fstab entry for %s, skipping mount validation.", instances_path) | ||
| return True | ||
|
|
||
| LOG.info("fstab entry found for %s, validating mount.", instances_path) | ||
|
|
||
| if not is_mounted(instances_path): | ||
| LOG.error( | ||
| "Instances path %s is declared in /etc/fstab but is not mounted. " | ||
| "nova-compute will not start until the path is mounted.", | ||
| instances_path, | ||
| ) | ||
| return False | ||
|
|
||
| if not is_usable(instances_path): | ||
| LOG.error( | ||
| "Instances path %s is mounted but not a writable directory." | ||
| " Check mount status, filesystem health, and permissions. " | ||
| "nova-compute will not start.", | ||
| instances_path, | ||
| ) | ||
|
Raven-182 marked this conversation as resolved.
|
||
| return False | ||
|
|
||
| return True | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| # SPDX-FileCopyrightText: 2026 - Canonical Ltd | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from pathlib import Path | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
|
|
||
| from openstack_hypervisor.mount_validation import ( | ||
| is_mounted, | ||
| is_usable, | ||
| path_declared_in_fstab, | ||
| validate_instances_mount, | ||
| ) | ||
|
|
||
| _INSTANCES_PATH = Path("/var/snap/openstack-hypervisor/common/lib/nova/instances") | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def fstab(tmp_path): | ||
| return tmp_path / "fstab" | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def instances_dir(tmp_path): | ||
| d = tmp_path / "instances" | ||
| d.mkdir() | ||
| return d | ||
|
|
||
|
|
||
| class TestPathDeclaredInFstab: | ||
| """Tests for path_declared_in_fstab().""" | ||
|
|
||
| def test_returns_false_when_no_matching_entry(self, fstab): | ||
| """Returns False when no fstab entry targets the instances path.""" | ||
| fstab.write_text("/dev/sda1 / ext4 defaults 0 1\n" "/dev/sda2 /boot ext4 defaults 0 2\n") | ||
| assert path_declared_in_fstab(_INSTANCES_PATH, fstab) is False | ||
|
|
||
| def test_returns_true_when_matching_entry_exists(self, fstab): | ||
| """Returns True when fstab has a line whose mount target matches.""" | ||
| fstab.write_text( | ||
| "/dev/sda1 / ext4 defaults 0 1\n" f"/dev/sdb1 {_INSTANCES_PATH} ext4 defaults 0 0\n" | ||
| ) | ||
| assert path_declared_in_fstab(_INSTANCES_PATH, fstab) is True | ||
|
|
||
| def test_returns_false_when_fstab_unreadable(self, tmp_path): | ||
| """Returns False when fstab cannot be read.""" | ||
| missing = tmp_path / "not_fstab" | ||
| assert path_declared_in_fstab(_INSTANCES_PATH, missing) is False | ||
|
|
||
| def test_ignores_comments_and_blank_lines(self, fstab): | ||
| fstab.write_text("\n" "# comment\n" f"/dev/sdb {_INSTANCES_PATH} ext4 defaults 0 0\n") | ||
| assert path_declared_in_fstab(_INSTANCES_PATH, fstab) is True | ||
|
|
||
|
|
||
| class TestIsMounted: | ||
| """Tests for is_mounted().""" | ||
|
|
||
| def test_returns_true_when_findmnt_succeeds(self): | ||
| """Returns True when findmnt exits with returncode 0.""" | ||
| mock_result = MagicMock(returncode=0) | ||
| with patch( | ||
| "openstack_hypervisor.mount_validation.subprocess.run", return_value=mock_result | ||
| ) as mock_run: | ||
| assert is_mounted(_INSTANCES_PATH) is True | ||
| mock_run.assert_called_once_with( | ||
| ["findmnt", "--mountpoint", str(_INSTANCES_PATH)], | ||
| capture_output=True, | ||
| check=False, | ||
| ) | ||
|
|
||
| def test_returns_false_when_findmnt_fails(self): | ||
| """Returns False when findmnt exits with a non-zero returncode.""" | ||
| mock_result = MagicMock(returncode=1) | ||
| with patch( | ||
| "openstack_hypervisor.mount_validation.subprocess.run", return_value=mock_result | ||
| ): | ||
| assert is_mounted(_INSTANCES_PATH) is False | ||
|
|
||
|
|
||
| class TestIsUsable: | ||
| """Tests for is_usable().""" | ||
|
|
||
| def test_returns_true_for_writable_directory(self, instances_dir): | ||
| """Returns True when the path is a directory and a file can be created.""" | ||
| assert is_usable(instances_dir) is True | ||
|
|
||
| def test_returns_false_when_path_does_not_exist(self, tmp_path): | ||
| """Returns False when the path does not exist.""" | ||
| assert is_usable(tmp_path / "nonexistent") is False | ||
|
|
||
| def test_returns_false_when_path_is_a_file(self, tmp_path): | ||
| """Returns False when the path is a regular file, not a directory.""" | ||
| f = tmp_path / "notadir" | ||
| f.write_text("content") | ||
| assert is_usable(f) is False | ||
|
|
||
| def test_returns_false_when_write_fails(self, instances_dir): | ||
| """Returns False when a temporary file cannot be created in the directory.""" | ||
| with patch( | ||
| "openstack_hypervisor.mount_validation.tempfile.TemporaryFile", | ||
| side_effect=OSError("read-only"), | ||
| ): | ||
| assert is_usable(instances_dir) is False | ||
|
|
||
|
|
||
| class TestValidateInstancesMount: | ||
| """Tests for validate_instances_mount().""" | ||
|
|
||
| def test_returns_true_when_no_fstab_entry(self, fstab): | ||
| """Returns True without checking mount when no fstab entry exists.""" | ||
| fstab.write_text("/dev/sda1 / ext4 defaults 0 1\n") | ||
| with patch("openstack_hypervisor.mount_validation.is_mounted") as mock_mounted: | ||
| result = validate_instances_mount(_INSTANCES_PATH, fstab) | ||
| assert result is True | ||
| mock_mounted.assert_not_called() | ||
|
|
||
| def test_returns_true_when_mounted_and_usable(self, fstab): | ||
| """Returns True when fstab entry exists, path is mounted, and writable.""" | ||
| fstab.write_text(f"/dev/sdb1 {_INSTANCES_PATH} ext4 defaults 0 0\n") | ||
| with patch("openstack_hypervisor.mount_validation.is_mounted", return_value=True), patch( | ||
| "openstack_hypervisor.mount_validation.is_usable", return_value=True | ||
| ): | ||
| assert validate_instances_mount(_INSTANCES_PATH, fstab) is True | ||
|
|
||
| def test_returns_false_when_fstab_entry_but_not_mounted(self, fstab): | ||
| """Returns False when fstab entry exists but path is not mounted.""" | ||
| fstab.write_text(f"/dev/sdb1 {_INSTANCES_PATH} ext4 defaults 0 0\n") | ||
| with patch("openstack_hypervisor.mount_validation.is_mounted", return_value=False), patch( | ||
| "openstack_hypervisor.mount_validation.LOG" | ||
| ) as mock_log: | ||
| assert validate_instances_mount(_INSTANCES_PATH, fstab) is False | ||
| assert "not mounted" in mock_log.error.call_args.args[0] | ||
|
|
||
| def test_returns_false_when_mounted_but_not_usable(self, fstab): | ||
| """Returns False when path is mounted but not writable.""" | ||
| fstab.write_text(f"/dev/sdb1 {_INSTANCES_PATH} ext4 defaults 0 0\n") | ||
| with patch("openstack_hypervisor.mount_validation.is_mounted", return_value=True), patch( | ||
| "openstack_hypervisor.mount_validation.is_usable", return_value=False | ||
| ), patch("openstack_hypervisor.mount_validation.LOG") as mock_log: | ||
| assert validate_instances_mount(_INSTANCES_PATH, fstab) is False | ||
| assert "not a writable directory" in mock_log.error.call_args.args[0] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.