diff --git a/charms/openstack-hypervisor/.sunbeam-build.yaml b/charms/openstack-hypervisor/.sunbeam-build.yaml index eb23a7ef..fa7a0d75 100644 --- a/charms/openstack-hypervisor/.sunbeam-build.yaml +++ b/charms/openstack-hypervisor/.sunbeam-build.yaml @@ -2,6 +2,7 @@ external-libraries: - charms.data_platform_libs.v0.data_interfaces - charms.grafana_agent.v0.cos_agent - charms.operator_libs_linux.v2.snap + - charms.operator_libs_linux.v1.systemd - charms.rabbitmq_k8s.v0.rabbitmq - charms.traefik_k8s.v2.ingress - charms.tls_certificates_interface.v3.tls_certificates diff --git a/charms/openstack-hypervisor/src/charm.py b/charms/openstack-hypervisor/src/charm.py index 3d381e83..bd749229 100755 --- a/charms/openstack-hypervisor/src/charm.py +++ b/charms/openstack-hypervisor/src/charm.py @@ -57,6 +57,9 @@ from charms.nova_k8s.v0.nova_service import ( NovaConfigChangedEvent, NovaServiceGoneAwayEvent, ) +from charms.operator_libs_linux.v1.systemd import ( + service_running, +) from cryptography import ( x509, ) @@ -578,8 +581,21 @@ class HypervisorOperatorCharm(sunbeam_charm.OSBaseOperatorCharm): """Return snap cache.""" return snap.SnapCache() + def check_system_services(self) -> None: + """Check if system services are in desired state.""" + if service_running("openvswitch-switch.service"): + logger.error( + "OpenVSwitch service is running, please stop it before proceeding. " + "OpenVSwitch is managed by the openstack-hypervisor snap and will " + "conflict with the snap's operation." + ) + raise sunbeam_guard.BlockedExceptionError( + "Breaking: OpenVSwitch service is running on the host." + ) + def configure_unit(self, event) -> None: """Run configuration on this unit.""" + self.check_system_services() self.check_leader_ready() self.check_relation_handlers_ready(event) config = self.model.config.get diff --git a/charms/openstack-hypervisor/tests/unit/test_charm.py b/charms/openstack-hypervisor/tests/unit/test_charm.py index 20d9fdc7..1325df8d 100644 --- a/charms/openstack-hypervisor/tests/unit/test_charm.py +++ b/charms/openstack-hypervisor/tests/unit/test_charm.py @@ -25,6 +25,7 @@ import charms.operator_libs_linux.v2.snap as snap import ops import ops.testing import ops_sunbeam.test_utils as test_utils +from ops_sunbeam import guard as sunbeam_guard class _HypervisorOperatorCharm(charm.HypervisorOperatorCharm): @@ -58,11 +59,13 @@ class TestCharm(test_utils.CharmTestCase): "os", "subprocess", "ConsulNotifyRequirer", + "service_running", ] def setUp(self): """Setup OpenStack Hypervisor tests.""" super().setUp(charm, self.PATCHES) + self.service_running.return_value = False self.snap.SnapError = Exception self.consul_notify_mock = MagicMock() @@ -463,3 +466,17 @@ class TestCharm(test_utils.CharmTestCase): with self.assertRaises(snap.SnapError): self.harness.charm._connect_to_epa_orchestrator() + + def test_check_system_services_raises_when_ovs_running(self): + """Test check_system_services raises BlockedExceptionError if OVS is running.""" + self.service_running.return_value = True + self.harness.begin() + with self.assertRaises(sunbeam_guard.BlockedExceptionError): + self.harness.charm.check_system_services() + + def test_check_system_services_passes_when_ovs_not_running(self): + """Test check_system_services does nothing if OVS is not running.""" + self.service_running.return_value = False + self.harness.begin() + # Should not raise + self.harness.charm.check_system_services() diff --git a/libs/external/lib/charms/operator_libs_linux/v1/systemd.py b/libs/external/lib/charms/operator_libs_linux/v1/systemd.py new file mode 100644 index 00000000..cdcbad6a --- /dev/null +++ b/libs/external/lib/charms/operator_libs_linux/v1/systemd.py @@ -0,0 +1,288 @@ +# Copyright 2021 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Abstractions for stopping, starting and managing system services via systemd. + +This library assumes that your charm is running on a platform that uses systemd. E.g., +Centos 7 or later, Ubuntu Xenial (16.04) or later. + +For the most part, we transparently provide an interface to a commonly used selection of +systemd commands, with a few shortcuts baked in. For example, service_pause and +service_resume with run the mask/unmask and enable/disable invocations. + +Example usage: + +```python +from charms.operator_libs_linux.v0.systemd import service_running, service_reload + +# Start a service +if not service_running("mysql"): + success = service_start("mysql") + +# Attempt to reload a service, restarting if necessary +success = service_reload("nginx", restart_on_failure=True) +``` +""" + +__all__ = [ # Don't export `_systemctl`. (It's not the intended way of using this lib.) + "SystemdError", + "daemon_reload", + "service_disable", + "service_enable", + "service_failed", + "service_pause", + "service_reload", + "service_restart", + "service_resume", + "service_running", + "service_start", + "service_stop", +] + +import logging +import subprocess + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "045b0d179f6b4514a8bb9b48aee9ebaf" + +# Increment this major API version when introducing breaking changes +LIBAPI = 1 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 4 + + +class SystemdError(Exception): + """Custom exception for SystemD related errors.""" + + +def _systemctl(*args: str, check: bool = False) -> int: + """Control a system service using systemctl. + + Args: + *args: Arguments to pass to systemctl. + check: Check the output of the systemctl command. Default: False. + + Returns: + Returncode of systemctl command execution. + + Raises: + SystemdError: Raised if calling systemctl returns a non-zero returncode and check is True. + """ + cmd = ["systemctl", *args] + logger.debug(f"Executing command: {cmd}") + try: + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + encoding="utf-8", + check=check, + ) + logger.debug( + f"Command {cmd} exit code: {proc.returncode}. systemctl output:\n{proc.stdout}" + ) + return proc.returncode + except subprocess.CalledProcessError as e: + raise SystemdError( + f"Command {cmd} failed with returncode {e.returncode}. systemctl output:\n{e.stdout}" + ) + + +def service_running(service_name: str) -> bool: + """Report whether a system service is running. + + Args: + service_name: The name of the service to check. + + Return: + True if service is running/active; False if not. + """ + # If returncode is 0, this means that is service is active. + return _systemctl("--quiet", "is-active", service_name) == 0 + + +def service_failed(service_name: str) -> bool: + """Report whether a system service has failed. + + Args: + service_name: The name of the service to check. + + Returns: + True if service is marked as failed; False if not. + """ + # If returncode is 0, this means that the service has failed. + return _systemctl("--quiet", "is-failed", service_name) == 0 + + +def service_start(*args: str) -> bool: + """Start a system service. + + Args: + *args: Arguments to pass to `systemctl start` (normally the service name). + + Returns: + On success, this function returns True for historical reasons. + + Raises: + SystemdError: Raised if `systemctl start ...` returns a non-zero returncode. + """ + return _systemctl("start", *args, check=True) == 0 + + +def service_stop(*args: str) -> bool: + """Stop a system service. + + Args: + *args: Arguments to pass to `systemctl stop` (normally the service name). + + Returns: + On success, this function returns True for historical reasons. + + Raises: + SystemdError: Raised if `systemctl stop ...` returns a non-zero returncode. + """ + return _systemctl("stop", *args, check=True) == 0 + + +def service_restart(*args: str) -> bool: + """Restart a system service. + + Args: + *args: Arguments to pass to `systemctl restart` (normally the service name). + + Returns: + On success, this function returns True for historical reasons. + + Raises: + SystemdError: Raised if `systemctl restart ...` returns a non-zero returncode. + """ + return _systemctl("restart", *args, check=True) == 0 + + +def service_enable(*args: str) -> bool: + """Enable a system service. + + Args: + *args: Arguments to pass to `systemctl enable` (normally the service name). + + Returns: + On success, this function returns True for historical reasons. + + Raises: + SystemdError: Raised if `systemctl enable ...` returns a non-zero returncode. + """ + return _systemctl("enable", *args, check=True) == 0 + + +def service_disable(*args: str) -> bool: + """Disable a system service. + + Args: + *args: Arguments to pass to `systemctl disable` (normally the service name). + + Returns: + On success, this function returns True for historical reasons. + + Raises: + SystemdError: Raised if `systemctl disable ...` returns a non-zero returncode. + """ + return _systemctl("disable", *args, check=True) == 0 + + +def service_reload(service_name: str, restart_on_failure: bool = False) -> bool: + """Reload a system service, optionally falling back to restart if reload fails. + + Args: + service_name: The name of the service to reload. + restart_on_failure: + Boolean indicating whether to fall back to a restart if the reload fails. + + Returns: + On success, this function returns True for historical reasons. + + Raises: + SystemdError: Raised if `systemctl reload|restart ...` returns a non-zero returncode. + """ + try: + return _systemctl("reload", service_name, check=True) == 0 + except SystemdError: + if restart_on_failure: + return service_restart(service_name) + else: + raise + + +def service_pause(service_name: str) -> bool: + """Pause a system service. + + Stops the service and prevents the service from starting again at boot. + + Args: + service_name: The name of the service to pause. + + Returns: + On success, this function returns True for historical reasons. + + Raises: + SystemdError: Raised if service is still running after being paused by systemctl. + """ + _systemctl("disable", "--now", service_name) + _systemctl("mask", service_name) + + if service_running(service_name): + raise SystemdError(f"Attempted to pause {service_name!r}, but it is still running.") + + return True + + +def service_resume(service_name: str) -> bool: + """Resume a system service. + + Re-enable starting the service again at boot. Start the service. + + Args: + service_name: The name of the service to resume. + + Returns: + On success, this function returns True for historical reasons. + + Raises: + SystemdError: Raised if service is not running after being resumed by systemctl. + """ + _systemctl("unmask", service_name) + _systemctl("enable", "--now", service_name) + + if not service_running(service_name): + raise SystemdError(f"Attempted to resume {service_name!r}, but it is not running.") + + return True + + +def daemon_reload() -> bool: + """Reload systemd manager configuration. + + Returns: + On success, this function returns True for historical reasons. + + Raises: + SystemdError: Raised if `systemctl daemon-reload` returns a non-zero returncode. + """ + return _systemctl("daemon-reload", check=True) == 0